mirror of
https://github.com/apache/superset.git
synced 2026-04-28 12:34:23 +00:00
Compare commits
39 Commits
semantic-l
...
v2020.51.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a861af7ac | ||
|
|
c8343a637f | ||
|
|
66a0d9088b | ||
|
|
1a7c0beb48 | ||
|
|
1fe221422a | ||
|
|
b21c90d780 | ||
|
|
53169d2459 | ||
|
|
b6e8e595f7 | ||
|
|
28e099a312 | ||
|
|
60781a05d6 | ||
|
|
617d6e51a9 | ||
|
|
aa9622c272 | ||
|
|
70e83e811c | ||
|
|
722e2ea204 | ||
|
|
0b0891cf1c | ||
|
|
cd8906c181 | ||
|
|
58d577989f | ||
|
|
d6d4eb7644 | ||
|
|
33bfa90086 | ||
|
|
2085b4c6dc | ||
|
|
9ac25898fb | ||
|
|
098ea0c850 | ||
|
|
c69b517bfc | ||
|
|
cb4c79c499 | ||
|
|
7a9488fef7 | ||
|
|
bc5b815976 | ||
|
|
16743c3541 | ||
|
|
04d3559d88 | ||
|
|
88ae1455a4 | ||
|
|
4253edd91a | ||
|
|
b744308cba | ||
|
|
5f8cf8ce11 | ||
|
|
08136cffc9 | ||
|
|
e25e869b50 | ||
|
|
d973bd8602 | ||
|
|
ce05a3dde3 | ||
|
|
c6a08fdc46 | ||
|
|
09589059a1 | ||
|
|
666e08b819 |
@@ -23,6 +23,7 @@ This file documents any backwards-incompatible changes in Superset and
|
||||
assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
- [11509](https://github.com/apache/superset/pull/12491): Dataset metadata updates check user ownership, only owners or an Admin are allowed.
|
||||
- [11499](https://github.com/apache/incubator-superset/pull/11499): Breaking change: `STORE_CACHE_KEYS_IN_METADATA_DB` config flag added (default=`False`) to write `CacheKey` records to the metadata DB. `CacheKey` recording was enabled by default previously.
|
||||
- [11920](https://github.com/apache/incubator-superset/pull/11920): Undos the DB migration from [11714](https://github.com/apache/incubator-superset/pull/11714) to prevent adding new columns to the logs table. Deploying a sha between these two PRs may result in locking your DB.
|
||||
- [11704](https://github.com/apache/incubator-superset/pull/11704) Breaking change: Jinja templating for SQL queries has been updated, removing default modules such as `datetime` and `random` and enforcing static template values. To restore or extend functionality, use `JINJA_CONTEXT_ADDONS` and `CUSTOM_TEMPLATE_PROCESSORS` in `superset_config.py`.
|
||||
|
||||
@@ -24,6 +24,7 @@ chardet==3.0.4 # via aiohttp
|
||||
click==7.1.2 # via apache-superset, flask, flask-appbuilder
|
||||
colorama==0.4.4 # via apache-superset, flask-appbuilder
|
||||
contextlib2==0.6.0.post1 # via apache-superset
|
||||
convertdate==2.3.0 # via holidays
|
||||
cron-descriptor==1.2.24 # via apache-superset
|
||||
croniter==0.3.36 # via apache-superset
|
||||
cryptography==3.2.1 # via apache-superset
|
||||
@@ -46,6 +47,7 @@ flask==1.1.2 # via apache-superset, flask-appbuilder, flask-babel,
|
||||
geographiclib==1.50 # via geopy
|
||||
geopy==2.0.0 # via apache-superset
|
||||
gunicorn==20.0.4 # via apache-superset
|
||||
holidays==0.10.3 # via apache-superset
|
||||
humanize==3.1.0 # via apache-superset
|
||||
idna==2.10 # via email-validator, yarl
|
||||
importlib-metadata==2.1.1 # via -r requirements/base.in, jsonschema, kombu, markdown
|
||||
@@ -54,6 +56,7 @@ itsdangerous==1.1.0 # via flask, flask-wtf
|
||||
jinja2==2.11.2 # via flask, flask-babel
|
||||
jsonschema==3.2.0 # via flask-appbuilder
|
||||
kombu==4.6.11 # via celery
|
||||
korean-lunar-calendar==0.2.1 # via holidays
|
||||
mako==1.1.3 # via alembic
|
||||
markdown==3.3.3 # via apache-superset
|
||||
markupsafe==1.1.1 # via jinja2, mako, wtforms
|
||||
@@ -75,20 +78,21 @@ py==1.9.0 # via retry
|
||||
pyarrow==1.0.1 # via apache-superset
|
||||
pycparser==2.20 # via cffi
|
||||
pyjwt==1.7.1 # via flask-appbuilder, flask-jwt-extended
|
||||
pyparsing==2.4.7 # via packaging
|
||||
pymeeus==0.3.7 # via convertdate
|
||||
pyparsing==2.4.7 # via apache-superset, packaging
|
||||
pyrsistent==0.16.1 # via -r requirements/base.in, jsonschema
|
||||
python-dateutil==2.8.1 # via alembic, apache-superset, croniter, flask-appbuilder, pandas
|
||||
python-dateutil==2.8.1 # via alembic, apache-superset, croniter, flask-appbuilder, holidays, pandas
|
||||
python-dotenv==0.15.0 # via apache-superset
|
||||
python-editor==1.0.4 # via alembic
|
||||
python-geohash==0.8.5 # via apache-superset
|
||||
python3-openid==3.2.0 # via flask-openid
|
||||
pytz==2020.4 # via babel, celery, flask-babel, pandas
|
||||
pytz==2020.4 # via babel, celery, convertdate, flask-babel, pandas
|
||||
pyyaml==5.3.1 # via apache-superset, apispec
|
||||
redis==3.5.3 # via apache-superset
|
||||
retry==0.9.2 # via apache-superset
|
||||
selenium==3.141.0 # via apache-superset
|
||||
simplejson==3.17.2 # via apache-superset
|
||||
six==1.15.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, isodate, jsonschema, packaging, pathlib2, polyline, prison, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json
|
||||
six==1.15.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, holidays, isodate, jsonschema, pathlib2, polyline, prison, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json
|
||||
slackclient==2.5.0 # via apache-superset
|
||||
sqlalchemy-utils==0.36.8 # via apache-superset, flask-appbuilder
|
||||
sqlalchemy==1.3.20 # via alembic, apache-superset, flask-sqlalchemy, marshmallow-sqlalchemy, sqlalchemy-utils
|
||||
|
||||
@@ -30,7 +30,7 @@ combine_as_imports = true
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
known_first_party = superset
|
||||
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,pkg_resources,polyline,prison,pyarrow,pyhive,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,werkzeug,wtforms,wtforms_json,yaml
|
||||
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,holidays,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,pkg_resources,polyline,prison,pyarrow,pyhive,pyparsing,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,werkzeug,wtforms,wtforms_json,yaml
|
||||
multi_line_output = 3
|
||||
order_by_type = false
|
||||
|
||||
|
||||
2
setup.py
2
setup.py
@@ -106,6 +106,8 @@ setup(
|
||||
"sqlalchemy-utils>=0.36.6,<0.37",
|
||||
"sqlparse==0.3.0", # PINNED! see https://github.com/andialbrecht/sqlparse/issues/562
|
||||
"wtforms-json",
|
||||
"pyparsing>=2.4.7, <3.0.0",
|
||||
"holidays==0.10.3", # PINNED! https://github.com/dr-prodigy/python-holidays/issues/406
|
||||
],
|
||||
extras_require={
|
||||
"athena": ["pyathena>=1.10.8,<1.11"],
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('chart card view filters', () => {
|
||||
cy.get('[data-test="styled-card"]').should('not.exist');
|
||||
});
|
||||
|
||||
it('should filter by viz type correctly', () => {
|
||||
xit('should filter by viz type correctly', () => {
|
||||
// filter by viz type
|
||||
cy.get('.Select__control').eq(2).click();
|
||||
cy.get('.Select__menu').contains('area').click({ timeout: 5000 });
|
||||
@@ -124,7 +124,8 @@ describe('chart list view filters', () => {
|
||||
cy.get('[data-test="table-row"]').should('not.exist');
|
||||
});
|
||||
|
||||
it('should filter by viz type correctly', () => {
|
||||
// this is flaky, but seems to fail along with the card view test of the same name
|
||||
xit('should filter by viz type correctly', () => {
|
||||
// filter by viz type
|
||||
cy.get('.Select__control').eq(2).click();
|
||||
cy.get('.Select__menu').contains('area').click({ timeout: 5000 });
|
||||
|
||||
@@ -65,30 +65,26 @@ describe('Dashboard filter', () => {
|
||||
cy.wait(aliases);
|
||||
});
|
||||
});
|
||||
xit('should apply filter', () => {
|
||||
cy.get('.Select__control input[type=text]')
|
||||
.first()
|
||||
.should('be.visible')
|
||||
.focus();
|
||||
|
||||
it('should apply filter', () => {
|
||||
cy.get('.Select__placeholder:first').click();
|
||||
|
||||
// should open the filter indicator
|
||||
cy.get('[data-test="filter"]')
|
||||
.should('be.visible', { timeout: 10000 })
|
||||
.should(nodes => {
|
||||
expect(nodes).to.have.length(9); // this part was not working, xit-ed
|
||||
});
|
||||
cy.get('svg[data-test="filter"]').should('be.visible');
|
||||
|
||||
cy.get('[data-test="chart-container"]').find('svg').should('be.visible');
|
||||
|
||||
cy.get('.Select__control input[type=text]').first().focus().blur();
|
||||
|
||||
cy.get('.Select__control input[type=text]')
|
||||
.first()
|
||||
.focus()
|
||||
.type('So', { force: true, delay: 100 });
|
||||
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
|
||||
// and since the select is closed, all filter indicators should be visible
|
||||
cy.get('svg[data-test="filter"]:visible').should(nodes => {
|
||||
expect(nodes).to.have.length(10);
|
||||
});
|
||||
|
||||
cy.get('.filter_box button').click({ force: true });
|
||||
cy.wait(aliases.filter(x => x !== getAlias(filterId))).then(requests => {
|
||||
return Promise.all(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { TABBED_DASHBOARD } from './dashboard.helper';
|
||||
|
||||
describe('Nativefilters', () => {
|
||||
beforeEach(() => {
|
||||
cy.login();
|
||||
cy.server();
|
||||
cy.visit(TABBED_DASHBOARD);
|
||||
});
|
||||
it('should show filter bar and allow user to create filters ', () => {
|
||||
cy.get('[data-test="filter-bar"]').should('be.visible');
|
||||
cy.get('[data-test="collapse"]').click();
|
||||
cy.get('[data-test="create-filter"]').click();
|
||||
cy.get('.ant-modal').should('be.visible');
|
||||
|
||||
cy.get('.ant-form-vertical').find('.ant-tabs-nav-add').first().click();
|
||||
|
||||
cy.get('.ant-modal')
|
||||
.find('.ant-tabs-tab-btn')
|
||||
.first()
|
||||
.click({ force: true })
|
||||
.type('TEST_Filter');
|
||||
|
||||
cy.get('.ant-modal').find('[data-test="datasource-input"]').first().click();
|
||||
|
||||
cy.get('[data-test="datasource-input"]')
|
||||
.contains('wb_health_population')
|
||||
.click();
|
||||
|
||||
// possible bug with cypress where it is having issue discovering the field input
|
||||
// after it is enabled
|
||||
|
||||
/* cy.get('.ant-modal')
|
||||
.find('[data-test="field-input"]')
|
||||
.click()
|
||||
.contains('country_name')
|
||||
.click();
|
||||
*/
|
||||
|
||||
cy.get('.ant-modal-footer').find('button').should('be.visible');
|
||||
});
|
||||
});
|
||||
@@ -177,7 +177,7 @@ describe('Dashboard tabs', () => {
|
||||
const requestParams = JSON.parse(requestFormData.get('form_data'));
|
||||
expect(requestParams.extra_filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'in',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,7 @@ describe('Dashboard tabs', () => {
|
||||
const requestParams = JSON.parse(requestFormData.get('form_data'));
|
||||
expect(requestParams.extra_filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'in',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
});
|
||||
@@ -214,7 +214,7 @@ describe('Dashboard tabs', () => {
|
||||
const requestParams = JSON.parse(requestFormData.get('form_data'));
|
||||
expect(requestParams.extra_filters[0]).deep.eq({
|
||||
col: 'region',
|
||||
op: 'in',
|
||||
op: 'IN',
|
||||
val: ['South Asia'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('Dashboard form data', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply url params and queryFields to slice requests', () => {
|
||||
it('should apply url params to slice requests', () => {
|
||||
const aliases = getChartAliases(dashboard.slices);
|
||||
// wait and verify one-by-one
|
||||
cy.wait(aliases).then(requests => {
|
||||
@@ -48,7 +48,6 @@ describe('Dashboard form data', () => {
|
||||
if (isLegacyResponse(responseBody)) {
|
||||
const requestFormData = xhr.request.body;
|
||||
const requestParams = JSON.parse(requestFormData.get('form_data'));
|
||||
expect(requestParams).to.have.property('queryFields');
|
||||
expect(requestParams.url_params).deep.eq(urlParams);
|
||||
} else {
|
||||
xhr.request.body.queries.forEach(query => {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('dashboard list view', () => {
|
||||
cy.get('[data-test="table-row"]').should('have.length', 4); // failed, xit-ed
|
||||
});
|
||||
|
||||
it('should sort correctly', () => {
|
||||
xit('should sort correctly', () => {
|
||||
cy.get('[data-test="sort-header"]').eq(1).click();
|
||||
cy.get('[data-test="sort-header"]').eq(1).click();
|
||||
cy.get('[data-test="table-row"]')
|
||||
|
||||
@@ -112,18 +112,4 @@ describe('AdhocFilters', () => {
|
||||
chartSelector: 'svg',
|
||||
});
|
||||
});
|
||||
|
||||
it('Click save without making any changes', () => {
|
||||
cy.get('[data-test=adhoc_filters]').within(() => {
|
||||
cy.get('.Select__control').scrollIntoView().click();
|
||||
cy.get('input[type=text]').focus().type('name{enter}');
|
||||
});
|
||||
|
||||
cy.get('[data-test=filter-edit-popover]').should('be.visible');
|
||||
cy.get('[data-test="adhoc-filter-edit-popover-save-button"]').click();
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[data-test=filter-edit-popover]').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,22 +29,23 @@ describe('AdhocMetrics', () => {
|
||||
it('Clear metric and set simple adhoc metric', () => {
|
||||
const metric = 'sum(sum_girls)';
|
||||
const metricName = 'Sum Girls';
|
||||
cy.get('[data-test=metrics]').find('.Select__clear-indicator').click();
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('[data-test="remove-control-button"]')
|
||||
.click();
|
||||
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('.Select__control input')
|
||||
.type('sum_girls', { force: true });
|
||||
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('.Select__option--is-focused')
|
||||
.trigger('mousedown')
|
||||
.find('[data-test="add-metric-button"]')
|
||||
.click();
|
||||
|
||||
cy.get('[data-test="AdhocMetricEditTitle#trigger"]').click();
|
||||
cy.get('[data-test="AdhocMetricEditTitle#input"]').type(metricName);
|
||||
|
||||
cy.get('[name="select-column"]').click().type('sum_girls{enter}');
|
||||
cy.get('[name="select-aggregate"]').click().type('sum{enter}');
|
||||
|
||||
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
|
||||
|
||||
cy.get('.metrics-select .metric-option').contains(metricName);
|
||||
cy.get('[data-test="control-label"]').contains(metricName);
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.verifySliceSuccess({
|
||||
@@ -118,41 +119,4 @@ describe('AdhocMetrics', () => {
|
||||
chartSelector: 'svg',
|
||||
});
|
||||
});
|
||||
|
||||
it('Typing starts with aggregate function name', () => {
|
||||
// select column "num"
|
||||
cy.get('[data-test=metrics]').within(() => {
|
||||
cy.get('.Select__dropdown-indicator').click();
|
||||
cy.get('.Select__control input[type=text]').type('avg(');
|
||||
cy.get('.Select__option').contains('ds');
|
||||
cy.get('.Select__option').contains('name');
|
||||
cy.get('.Select__option').contains('sum_boys').click();
|
||||
});
|
||||
|
||||
const metric = 'AVG(sum_boys)';
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
cy.verifySliceSuccess({
|
||||
waitAlias: '@postJson',
|
||||
querySubstring: `${metric} AS "${metric}"`,
|
||||
chartSelector: 'svg',
|
||||
});
|
||||
});
|
||||
|
||||
it('Click save without making any changes', () => {
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('.Select__control input')
|
||||
.type('sum_girls', { force: true });
|
||||
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('.Select__option--is-focused')
|
||||
.trigger('mousedown')
|
||||
.click();
|
||||
|
||||
cy.get('[data-test=metrics-edit-popover]').should('be.visible');
|
||||
cy.get('[data-test="AdhocMetricEdit#save"]').click();
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[data-test=metrics-edit-popover]').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('No Results', () => {
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'state',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
comparator: ['Fake State'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
|
||||
@@ -24,7 +24,8 @@ import { FORM_DATA_DEFAULTS, NUM_METRIC } from './visualizations/shared.helper';
|
||||
describe('Datasource control', () => {
|
||||
const newMetricName = `abc${Date.now()}`;
|
||||
|
||||
it('should allow edit dataset', () => {
|
||||
// TODO: uncomment when adding metrics from dataset is fixed
|
||||
xit('should allow edit dataset', () => {
|
||||
let numScripts = 0;
|
||||
|
||||
cy.login();
|
||||
@@ -126,7 +127,7 @@ describe('Time range filter', () => {
|
||||
cy.route('POST', '/superset/explore_json/**').as('postJson');
|
||||
});
|
||||
|
||||
it('Defaults to the correct tab for time_range params', () => {
|
||||
it('Advanced time_range params', () => {
|
||||
const formData = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
metrics: [NUM_METRIC],
|
||||
@@ -137,20 +138,100 @@ describe('Time range filter', () => {
|
||||
cy.visitChartByParams(JSON.stringify(formData));
|
||||
cy.verifySliceSuccess({ waitAlias: '@postJson' });
|
||||
|
||||
cy.get('[data-test=time_range]').within(() => {
|
||||
cy.get('span.label').click();
|
||||
});
|
||||
|
||||
cy.get('#filter-popover').within(() => {
|
||||
cy.get('div.ant-tabs-tabpane-active').within(() => {
|
||||
cy.get('div.PopoverSection :not(.dimmed)').within(() => {
|
||||
cy.get('[data-test=time-range-trigger]')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.footer').find('button').its('length').should('eq', 2);
|
||||
cy.get('.ant-popover-content').within(() => {
|
||||
cy.get('input[value="100 years ago"]');
|
||||
cy.get('input[value="now"]');
|
||||
});
|
||||
cy.get('[data-test=cancel-button]').click();
|
||||
cy.get('.ant-popover').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
cy.get('#filter-popover button').contains('Ok').click();
|
||||
cy.get('#filter-popover').should('not.be.visible');
|
||||
});
|
||||
|
||||
it('Common time_range params', () => {
|
||||
const formData = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
metrics: [NUM_METRIC],
|
||||
viz_type: 'line',
|
||||
time_range: 'Last year',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(JSON.stringify(formData));
|
||||
cy.verifySliceSuccess({ waitAlias: '@postJson' });
|
||||
|
||||
cy.get('[data-test=time-range-trigger]')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.ant-radio-group').children().its('length').should('eq', 5);
|
||||
cy.get('.ant-radio-checked + span').contains('last year');
|
||||
cy.get('[data-test=cancel-button]').click();
|
||||
});
|
||||
});
|
||||
|
||||
it('Previous time_range params', () => {
|
||||
const formData = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
metrics: [NUM_METRIC],
|
||||
viz_type: 'line',
|
||||
time_range:
|
||||
'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH)',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(JSON.stringify(formData));
|
||||
cy.verifySliceSuccess({ waitAlias: '@postJson' });
|
||||
|
||||
cy.get('[data-test=time-range-trigger]')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.ant-radio-group').children().its('length').should('eq', 3);
|
||||
cy.get('.ant-radio-checked + span').contains('previous calendar month');
|
||||
cy.get('[data-test=cancel-button]').click();
|
||||
});
|
||||
});
|
||||
|
||||
it('Custom time_range params', () => {
|
||||
const formData = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
metrics: [NUM_METRIC],
|
||||
viz_type: 'line',
|
||||
time_range: 'DATEADD(DATETIME("today"), -7, day) : today',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(JSON.stringify(formData));
|
||||
cy.verifySliceSuccess({ waitAlias: '@postJson' });
|
||||
|
||||
cy.get('[data-test=time-range-trigger]')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('[data-test=custom-frame]').then(() => {
|
||||
cy.get('.ant-input-number-input-wrap > input')
|
||||
.invoke('attr', 'value')
|
||||
.should('eq', '7');
|
||||
});
|
||||
cy.get('[data-test=cancel-button]').click();
|
||||
});
|
||||
});
|
||||
|
||||
it('No filter time_range params', () => {
|
||||
const formData = {
|
||||
...FORM_DATA_DEFAULTS,
|
||||
metrics: [NUM_METRIC],
|
||||
viz_type: 'line',
|
||||
time_range: 'No filter',
|
||||
};
|
||||
|
||||
cy.visitChartByParams(JSON.stringify(formData));
|
||||
cy.verifySliceSuccess({ waitAlias: '@postJson' });
|
||||
|
||||
cy.get('[data-test=time-range-trigger]')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('[data-test=no-filter]');
|
||||
});
|
||||
cy.get('[data-test=cancel-button]').click();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('Visualization > Area', () => {
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'region',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
comparator: ['South Asia', 'North America'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Visualization > Big Number Total', () => {
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'name',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
comparator: ['Aaron', 'Amy', 'Andrea'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
|
||||
@@ -46,9 +46,14 @@ describe('Visualization > Line', () => {
|
||||
cy.visitChartByParams(JSON.stringify(formData));
|
||||
cy.get('.alert-warning').contains(`"Metrics" cannot be empty`);
|
||||
cy.get('.text-danger').contains('Metrics');
|
||||
cy.get('.metrics-select .Select__input input:eq(0)')
|
||||
.focus()
|
||||
.type('SUM(num){enter}');
|
||||
|
||||
cy.get('[data-test=metrics]')
|
||||
.find('[data-test="add-metric-button"]')
|
||||
.click();
|
||||
cy.get('[name="select-column"]').click().type('num{enter}');
|
||||
cy.get('[name="select-aggregate"]').click().type('sum{enter}');
|
||||
cy.get('[data-test="AdhocMetricEdit#save"]').contains('Save').click();
|
||||
|
||||
cy.get('.text-danger').should('not.exist');
|
||||
cy.get('.alert-warning').should('not.exist');
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ export const MAX_STATE = {
|
||||
export const SIMPLE_FILTER = {
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'name',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
comparator: ['Aaron', 'Amy', 'Andrea'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('Visualization > Sunburst', () => {
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'region',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
comparator: ['South Asia', 'North America'],
|
||||
clause: 'WHERE',
|
||||
sqlExpression: null,
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"eslint-plugin-cypress": "^2.11.1"
|
||||
},
|
||||
"nyc": {
|
||||
"reporter": ["html", "json"]
|
||||
"reporter": [
|
||||
"html",
|
||||
"json"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,5 @@ specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.59961 18.4C9.59961 19.2837 10.316 20 11.1996 20H12.7996C13.6833 20 14.3996 19.2837 14.3996 18.4V18.4C14.3996 17.5163 13.6833 16.8 12.7996 16.8H11.1996C10.316 16.8 9.59961 17.5163 9.59961 18.4V18.4ZM3.19961 4C2.31596 4 1.59961 4.71634 1.59961 5.6V5.6C1.59961 6.48366 2.31595 7.2 3.19961 7.2H20.7996C21.6833 7.2 22.3996 6.48366 22.3996 5.6V5.6C22.3996 4.71634 21.6833 4 20.7996 4H3.19961ZM6.39961 12C6.39961 12.8837 7.11595 13.6 7.99961 13.6H15.9996C16.8833 13.6 17.5996 12.8837 17.5996 12V12C17.5996 11.1163 16.8833 10.4 15.9996 10.4H7.99961C7.11595 10.4 6.39961 11.1163 6.39961 12V12Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.59961 17.8C9.59961 18.3523 10.0473 18.8 10.5996 18.8H13.3996C13.9519 18.8 14.3996 18.3523 14.3996 17.8V17.8C14.3996 17.2477 13.9519 16.8 13.3996 16.8H10.5996C10.0473 16.8 9.59961 17.2477 9.59961 17.8V17.8ZM2.59961 4C2.04732 4 1.59961 4.44772 1.59961 5V5C1.59961 5.55228 2.04732 6 2.59961 6H21.3996C21.9519 6 22.3996 5.55228 22.3996 5V5C22.3996 4.44772 21.9519 4 21.3996 4H2.59961ZM6.39961 11.4C6.39961 11.9523 6.84732 12.4 7.39961 12.4H16.5996C17.1519 12.4 17.5996 11.9523 17.5996 11.4V11.4C17.5996 10.8477 17.1519 10.4 16.5996 10.4H7.39961C6.84732 10.4 6.39961 10.8477 6.39961 11.4V11.4Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
21
superset-frontend/images/icons/function_x.svg
Normal file
21
superset-frontend/images/icons/function_x.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg width="16" height="11" viewBox="0 0 16 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.82355 4.0072L3.96417 8.04041C3.81118 8.76307 3.48891 9.35388 2.99738 9.81287C2.50584 10.2719 1.99966 10.5013 1.47882 10.5013C1.19236 10.5013 0.981588 10.4468 0.846497 10.3378C0.711405 10.2287 0.64386 10.0912 0.64386 9.92517C0.64386 9.7852 0.686177 9.6615 0.770813 9.55408C0.855449 9.44666 0.977518 9.39295 1.13702 9.39295C1.23794 9.39295 1.32664 9.41573 1.40314 9.4613C1.47963 9.50688 1.54718 9.56384 1.60577 9.6322C1.65135 9.68754 1.70262 9.76241 1.75958 9.85681C1.81655 9.95121 1.86619 10.0326 1.90851 10.101C2.15916 10.0814 2.37319 9.91215 2.5506 9.59314C2.72801 9.27413 2.88181 8.8184 3.01202 8.22595L3.91046 4.0072H2.95831L3.06085 3.56287H4.00323L4.07159 3.23084C4.14972 2.85323 4.27342 2.51306 4.44269 2.21033C4.61196 1.90759 4.80402 1.65043 5.01886 1.43884C5.23045 1.23051 5.47052 1.06694 5.73907 0.94812C6.00763 0.829304 6.2656 0.769897 6.513 0.769897C6.79946 0.769897 7.01023 0.824422 7.14532 0.933472C7.28042 1.04252 7.34796 1.18005 7.34796 1.34607C7.34796 1.48604 7.30809 1.60974 7.22833 1.71716C7.14858 1.82459 7.02407 1.8783 6.8548 1.8783C6.75389 1.8783 6.666 1.85632 6.59113 1.81238C6.51626 1.76843 6.44952 1.71065 6.39093 1.63904C6.32583 1.55766 6.27374 1.48116 6.23468 1.40955C6.19562 1.33793 6.14679 1.25818 6.0882 1.17029C5.86358 1.18005 5.66502 1.32816 5.49249 1.61462C5.31997 1.90108 5.16372 2.37797 5.02374 3.04529L4.91632 3.56287H6.14191L6.03937 4.0072H4.82355ZM6.67739 5.89197C6.67739 4.42712 7.05174 3.23897 7.83299 2.20544H8.42706C7.84926 2.946 7.3976 4.51664 7.3976 5.89197C7.3976 7.27543 7.84519 8.842 8.42706 9.58256H7.83299C7.05174 8.54903 6.67739 7.36088 6.67739 5.89197ZM11.0841 6.68949H11.019L9.97736 8.34558H9.1839L10.6854 6.15239L9.16762 3.95919H10.0018L11.0434 5.59086H11.1085L12.138 3.95919H12.9315L11.4422 6.1239L12.9518 8.34558H12.1217L11.0841 6.68949ZM15.442 5.89604C15.442 7.36088 15.0677 8.54903 14.2864 9.58256H13.6924C14.2702 8.842 14.7218 7.27136 14.7218 5.89604C14.7218 4.51257 14.2742 2.946 13.6924 2.20544H14.2864C15.0677 3.23897 15.442 4.42712 15.442 5.89604Z" fill="#323232"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
1363
superset-frontend/package-lock.json
generated
1363
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -65,37 +65,38 @@
|
||||
"@babel/runtime-corejs3": "^7.8.4",
|
||||
"@data-ui/sparkline": "^0.0.84",
|
||||
"@emotion/core": "^10.0.35",
|
||||
"@superset-ui/chart-controls": "^0.15.18",
|
||||
"@superset-ui/core": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-event-flow": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-force-directed": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-heatmap": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-histogram": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-horizon": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-map-box": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-partition": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-rose": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-sankey": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-sunburst": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-treemap": "^0.15.18",
|
||||
"@superset-ui/legacy-plugin-chart-world-map": "^0.15.18",
|
||||
"@superset-ui/legacy-preset-chart-big-number": "^0.15.18",
|
||||
"@superset-ui/legacy-preset-chart-deckgl": "^0.3.2",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "^0.15.18",
|
||||
"@superset-ui/plugin-chart-echarts": "^0.15.18",
|
||||
"@superset-ui/plugin-chart-table": "^0.15.18",
|
||||
"@superset-ui/plugin-chart-word-cloud": "^0.15.18",
|
||||
"@superset-ui/preset-chart-xy": "^0.15.18",
|
||||
"@superset-ui/chart-controls": "^0.16.4",
|
||||
"@superset-ui/core": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-event-flow": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-force-directed": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-heatmap": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-histogram": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-horizon": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-map-box": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-partition": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-rose": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-sankey": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-sunburst": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-treemap": "^0.16.4",
|
||||
"@superset-ui/legacy-plugin-chart-world-map": "^0.16.4",
|
||||
"@superset-ui/legacy-preset-chart-big-number": "^0.16.4",
|
||||
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.0",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "^0.16.4",
|
||||
"@superset-ui/plugin-chart-echarts": "^0.16.4",
|
||||
"@superset-ui/plugin-chart-table": "^0.16.4",
|
||||
"@superset-ui/plugin-chart-word-cloud": "^0.16.4",
|
||||
"@superset-ui/plugin-filter-antd": "^0.16.4",
|
||||
"@superset-ui/preset-chart-xy": "^0.16.4",
|
||||
"@vx/responsive": "^0.0.195",
|
||||
"abortcontroller-polyfill": "^1.1.9",
|
||||
"antd": "^4.8.2",
|
||||
"antd": "^4.9.4",
|
||||
"array-move": "^2.2.1",
|
||||
"bootstrap": "^3.4.1",
|
||||
"bootstrap-slider": "^10.0.0",
|
||||
@@ -115,6 +116,7 @@
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"interweave": "^11.2.0",
|
||||
"jquery": "^3.5.1",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.17.20",
|
||||
@@ -205,6 +207,7 @@
|
||||
"@types/fetch-mock": "^7.3.2",
|
||||
"@types/jest": "^26.0.3",
|
||||
"@types/jquery": "^3.3.32",
|
||||
"@types/js-levenshtein": "^1.1.0",
|
||||
"@types/json-bigint": "^1.0.0",
|
||||
"@types/react": "^16.9.43",
|
||||
"@types/react-bootstrap": "^0.32.22",
|
||||
@@ -214,6 +217,7 @@
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/react-select": "^3.0.19",
|
||||
"@types/react-sticky": "^6.0.3",
|
||||
"@types/react-table": "^7.0.19",
|
||||
"@types/react-ultimate-pagination": "^1.2.0",
|
||||
"@types/react-virtualized": "^9.21.10",
|
||||
@@ -273,12 +277,12 @@
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "^2.1.1",
|
||||
"react-test-renderer": "^16.9.0",
|
||||
"redux-mock-store": "^1.2.3",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"sinon": "^9.0.2",
|
||||
"source-map-support": "^0.5.16",
|
||||
"speed-measure-webpack-plugin": "^1.2.3",
|
||||
"storybook-addon-jsx": "^7.3.3",
|
||||
"storybook-addon-paddings": "^2.0.2",
|
||||
"storybook-addon-paddings": "^3.2.0",
|
||||
"style-loader": "^1.0.0",
|
||||
"terser-webpack-plugin": "^1.1.0",
|
||||
"thread-loader": "^1.2.0",
|
||||
|
||||
2
superset-frontend/spec/fixtures/mockState.js
vendored
2
superset-frontend/spec/fixtures/mockState.js
vendored
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import datasources from 'spec/fixtures/mockDatasource';
|
||||
import messageToasts from 'spec/javascripts/messageToasts/mockMessageToasts';
|
||||
import { nativeFiltersInfo } from 'spec/javascripts/dashboard/fixtures/mockNativeFilters';
|
||||
import chartQueries from './mockChartQueries';
|
||||
import { dashboardLayout } from './mockDashboardLayout';
|
||||
import dashboardInfo from './mockDashboardInfo';
|
||||
@@ -29,6 +30,7 @@ export default {
|
||||
datasources,
|
||||
sliceEntities,
|
||||
charts: chartQueries,
|
||||
nativeFilters: nativeFiltersInfo,
|
||||
dashboardInfo,
|
||||
dashboardFilters: emptyFilters,
|
||||
dashboardState,
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('chart actions', () => {
|
||||
expect(dispatch.callCount).toBe(5);
|
||||
const updateFailedAction = dispatch.args[4][0];
|
||||
expect(updateFailedAction.type).toBe(actions.CHART_UPDATE_FAILED);
|
||||
expect(updateFailedAction.queryResponse.error).toBe('misc error');
|
||||
expect(updateFailedAction.queriesResponse[0].error).toBe('misc error');
|
||||
|
||||
setupDefaultFetchMock();
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ import { getChartControlPanelRegistry } from '@superset-ui/core';
|
||||
import AlteredSliceTag from 'src/components/AlteredSliceTag';
|
||||
import ModalTrigger from 'src/components/ModalTrigger';
|
||||
import TooltipWrapper from 'src/components/TooltipWrapper';
|
||||
import ListView from 'src/components/ListView';
|
||||
import TableCollection from 'src/components/dataViewCommon/TableCollection';
|
||||
import TableView from 'src/components/TableView';
|
||||
|
||||
import {
|
||||
defaultProps,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from './fixtures/AlteredSliceTag';
|
||||
|
||||
const getTableWrapperFromModalBody = modalBody =>
|
||||
modalBody.find(ListView).find(TableCollection);
|
||||
modalBody.find(TableView).find(TableCollection);
|
||||
|
||||
describe('AlteredSliceTag', () => {
|
||||
let wrapper;
|
||||
@@ -110,7 +110,7 @@ describe('AlteredSliceTag', () => {
|
||||
const modalBody = mount(
|
||||
<div>{wrapper.instance().renderModalBody()}</div>,
|
||||
);
|
||||
expect(modalBody.find(ListView)).toHaveLength(1);
|
||||
expect(modalBody.find(TableView)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a thead', () => {
|
||||
@@ -241,18 +241,18 @@ describe('AlteredSliceTag', () => {
|
||||
clause: 'WHERE',
|
||||
comparator: ['1', 'g', '7', 'ho'],
|
||||
expressionType: 'SIMPLE',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
subject: 'a',
|
||||
},
|
||||
{
|
||||
clause: 'WHERE',
|
||||
comparator: ['hu', 'ho', 'ha'],
|
||||
expressionType: 'SIMPLE',
|
||||
operator: 'not in',
|
||||
operator: 'NOT IN',
|
||||
subject: 'b',
|
||||
},
|
||||
];
|
||||
const expected = 'a in [1, g, 7, ho], b not in [hu, ho, ha]';
|
||||
const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]';
|
||||
expect(
|
||||
wrapper.instance().formatValue(filters, 'adhoc_filters', controlsMap),
|
||||
).toBe(expected);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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 { mount } from 'enzyme';
|
||||
import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import SupersetResourceSelect from 'src/components/SupersetResourceSelect';
|
||||
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
|
||||
|
||||
describe('SupersetResourceSelect', () => {
|
||||
const NOOP = () => {};
|
||||
|
||||
it('is a valid element', () => {
|
||||
// @ts-ignore
|
||||
expect(
|
||||
React.isValidElement(<SupersetResourceSelect onError={NOOP} />),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('take in props', () => {
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
const selectProps = {
|
||||
resource: 'dataset',
|
||||
searchColumn: 'table_name',
|
||||
transformItem: jest.fn(),
|
||||
isMulti: false,
|
||||
onError: NOOP,
|
||||
};
|
||||
const wrapper = mount(<SupersetResourceSelect {...selectProps} />, {
|
||||
wrappingComponent: ({ children }) => (
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</ThemeProvider>
|
||||
),
|
||||
});
|
||||
expect(wrapper.props().resource).toEqual('dataset');
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export const defaultProps = {
|
||||
clause: 'WHERE',
|
||||
comparator: ['hello', 'my', 'name'],
|
||||
expressionType: 'SIMPLE',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
subject: 'b',
|
||||
},
|
||||
],
|
||||
@@ -73,7 +73,7 @@ export const expectedDiffs = {
|
||||
clause: 'WHERE',
|
||||
comparator: ['hello', 'my', 'name'],
|
||||
expressionType: 'SIMPLE',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
subject: 'b',
|
||||
},
|
||||
],
|
||||
@@ -107,7 +107,7 @@ export const expectedRows = [
|
||||
{
|
||||
control: 'Fake Filters',
|
||||
before: 'a == hello',
|
||||
after: 'b in [hello, my, name]',
|
||||
after: 'b IN [hello, my, name]',
|
||||
},
|
||||
{
|
||||
control: 'Value bounds',
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"no-prototype-builtins": 2,
|
||||
"class-methods-use-this": 2,
|
||||
"import/no-named-as-default": 2,
|
||||
"import/prefer-default-export": 2,
|
||||
"react/no-unescaped-entities": 2,
|
||||
"react/no-string-refs": 2,
|
||||
"react/jsx-indent": 0,
|
||||
|
||||
@@ -51,11 +51,13 @@ describe('FiltersBadge', () => {
|
||||
store.dispatch({
|
||||
type: CHART_UPDATE_SUCCEEDED,
|
||||
key: sliceId,
|
||||
queryResponse: {
|
||||
status: 'success',
|
||||
applied_filters: [],
|
||||
rejected_filters: [],
|
||||
},
|
||||
queriesResponse: [
|
||||
{
|
||||
status: 'success',
|
||||
applied_filters: [],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
dashboardFilters,
|
||||
});
|
||||
const wrapper = shallow(
|
||||
@@ -74,11 +76,13 @@ describe('FiltersBadge', () => {
|
||||
store.dispatch({
|
||||
type: CHART_UPDATE_SUCCEEDED,
|
||||
key: sliceId,
|
||||
queryResponse: {
|
||||
status: 'success',
|
||||
applied_filters: [{ column: 'region' }],
|
||||
rejected_filters: [],
|
||||
},
|
||||
queriesResponse: [
|
||||
{
|
||||
status: 'success',
|
||||
applied_filters: [{ column: 'region' }],
|
||||
rejected_filters: [],
|
||||
},
|
||||
],
|
||||
dashboardFilters,
|
||||
});
|
||||
const wrapper = shallow(
|
||||
@@ -97,11 +101,13 @@ describe('FiltersBadge', () => {
|
||||
store.dispatch({
|
||||
type: CHART_UPDATE_SUCCEEDED,
|
||||
key: sliceId,
|
||||
queryResponse: {
|
||||
status: 'success',
|
||||
applied_filters: [],
|
||||
rejected_filters: [{ column: 'region', reason: 'not_in_datasource' }],
|
||||
},
|
||||
queriesResponse: [
|
||||
{
|
||||
status: 'success',
|
||||
applied_filters: [],
|
||||
rejected_filters: [{ column: 'region', reason: 'not_in_datasource' }],
|
||||
},
|
||||
],
|
||||
dashboardFilters,
|
||||
});
|
||||
const wrapper = shallow(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 { Provider } from 'react-redux';
|
||||
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
|
||||
import { mockStore } from 'spec/fixtures/mockStore';
|
||||
|
||||
describe('FilterBar', () => {
|
||||
const props = {
|
||||
filtersOpen: false,
|
||||
toggleFiltersBar: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<Provider store={mockStore}>
|
||||
<FilterBar {...props} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
it('is a valid', () => {
|
||||
expect(React.isValidElement(<FilterBar {...props} />)).toBe(true);
|
||||
});
|
||||
it('has filter and collapse icons', () => {
|
||||
expect(wrapper.find({ name: 'filter' })).toExist();
|
||||
expect(wrapper.find({ name: 'collapse' })).toExist();
|
||||
});
|
||||
it('has apply and reset all buttons', () => {
|
||||
expect(wrapper.find('.btn-primary')).toExist();
|
||||
expect(wrapper.find('.btn-secondary')).toExist();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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 { Provider } from 'react-redux';
|
||||
import FilterConfigurationLink from 'src/dashboard/components/nativeFilters/FilterConfigurationLink';
|
||||
import { mockStore } from 'spec/fixtures/mockStore';
|
||||
|
||||
describe('FilterConfigurationButton', () => {
|
||||
const mockedProps = {
|
||||
createNewOnOpen: false,
|
||||
};
|
||||
it('it is valid', () => {
|
||||
expect(
|
||||
React.isValidElement(<FilterConfigurationLink {...mockedProps} />),
|
||||
).toBe(true);
|
||||
});
|
||||
it('takes in children', () => {
|
||||
const wrapper = mount(
|
||||
<Provider store={mockStore}>
|
||||
<FilterConfigurationLink {...mockedProps}>
|
||||
{' '}
|
||||
<span>Test</span>
|
||||
</FilterConfigurationLink>
|
||||
</Provider>,
|
||||
);
|
||||
expect(wrapper.find('span')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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 { act } from 'react-dom/test-utils';
|
||||
import { Provider } from 'react-redux';
|
||||
import { FilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterConfigModal';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import { mockStore } from 'spec/fixtures/mockStore';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe('FiltersConfigModal', () => {
|
||||
const mockedProps = {
|
||||
isOpen: true,
|
||||
initialFilterId: 'DefaultFilterId',
|
||||
createNewOnOpen: true,
|
||||
onCancel: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
function setup(overridesProps?: any) {
|
||||
return mount(
|
||||
<Provider store={mockStore}>
|
||||
<FilterConfigModal {...mockedProps} {...overridesProps} />
|
||||
</Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
it('should be a valid react element', () => {
|
||||
expect(React.isValidElement(<FilterConfigModal {...mockedProps} />)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('the form validates required fields', async () => {
|
||||
const onSave = jest.fn();
|
||||
const wrapper = setup({ save: onSave });
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('input')
|
||||
.first()
|
||||
.simulate('change', { target: { value: 'test name' } });
|
||||
|
||||
wrapper.find('.ant-modal-footer button').at(1).simulate('click');
|
||||
});
|
||||
await waitForComponentToPaint(wrapper);
|
||||
expect(onSave.mock.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 { Provider } from 'react-redux';
|
||||
import ScopingTree from 'src/dashboard/components/nativeFilters/ScopingTree';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import { mockStore } from 'spec/fixtures/mockStore';
|
||||
|
||||
describe('ScopingTree', () => {
|
||||
const mock = jest.fn();
|
||||
const wrapper = mount(
|
||||
<Provider store={mockStore}>
|
||||
<ScopingTree setFilterScope={mock} />
|
||||
</Provider>,
|
||||
);
|
||||
it('is valid', () => {
|
||||
const mock = () => null;
|
||||
expect(React.isValidElement(<ScopingTree setFilterScope={mock} />)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it('renders a tree', () => {
|
||||
expect(wrapper.find('TreeNode')).toExist();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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 const nativeFiltersInfo = {
|
||||
filters: {
|
||||
DefaultID1: {
|
||||
id: 'DefaultID1',
|
||||
name: 'test',
|
||||
type: 'text',
|
||||
targets: [
|
||||
{
|
||||
datasetId: 0,
|
||||
column: {
|
||||
name: 'test column',
|
||||
displayName: 'test column',
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultValue: null,
|
||||
scope: {
|
||||
rootPath: [],
|
||||
excluded: [],
|
||||
},
|
||||
isInstant: true,
|
||||
allowsMultipleValues: true,
|
||||
isRequired: false,
|
||||
},
|
||||
},
|
||||
filtersState: {
|
||||
DefaultsID: {
|
||||
id: 'DefaultId',
|
||||
selectedValues: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -28,7 +28,7 @@ describe('getEffectiveExtraFilters', () => {
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
col: 'gender',
|
||||
op: 'in',
|
||||
op: 'IN',
|
||||
val: ['girl'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('getFormDataWithExtraFilters', () => {
|
||||
filters: [
|
||||
{
|
||||
col: 'country_name',
|
||||
op: 'in',
|
||||
op: 'IN',
|
||||
val: ['United States'],
|
||||
},
|
||||
],
|
||||
@@ -37,6 +37,10 @@ describe('getFormDataWithExtraFilters', () => {
|
||||
region: ['Spain'],
|
||||
color: ['pink', 'purple'],
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: {},
|
||||
filtersState: {},
|
||||
},
|
||||
sliceId: chartId,
|
||||
};
|
||||
|
||||
@@ -45,12 +49,12 @@ describe('getFormDataWithExtraFilters', () => {
|
||||
expect(result.extra_filters).toHaveLength(2);
|
||||
expect(result.extra_filters[0]).toEqual({
|
||||
col: 'region',
|
||||
op: 'in',
|
||||
op: 'IN',
|
||||
val: ['Spain'],
|
||||
});
|
||||
expect(result.extra_filters[1]).toEqual({
|
||||
col: 'color',
|
||||
op: 'in',
|
||||
op: 'IN',
|
||||
val: ['pink', 'purple'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('AdhocFilter', () => {
|
||||
const adhocFilter4 = new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
subject: 'value',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
comparator: [],
|
||||
clause: CLAUSES.WHERE,
|
||||
});
|
||||
@@ -152,7 +152,7 @@ describe('AdhocFilter', () => {
|
||||
const adhocFilter5 = new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
subject: 'value',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
comparator: ['val1'],
|
||||
clause: CLAUSES.WHERE,
|
||||
});
|
||||
|
||||
@@ -20,13 +20,14 @@
|
||||
import React from 'react';
|
||||
import sinon from 'sinon';
|
||||
import { shallow } from 'enzyme';
|
||||
import { supersetTheme } from '@superset-ui/core';
|
||||
|
||||
import Select from 'src/components/Select';
|
||||
import AdhocFilter, {
|
||||
EXPRESSION_TYPES,
|
||||
CLAUSES,
|
||||
} from 'src/explore/AdhocFilter';
|
||||
import AdhocFilterControl from 'src/explore/components/controls/AdhocFilterControl';
|
||||
import { LabelsContainer } from 'src/explore/components/OptionControls';
|
||||
import AdhocMetric from 'src/explore/AdhocMetric';
|
||||
import { AGGREGATES, OPERATORS } from 'src/explore/constants';
|
||||
|
||||
@@ -66,22 +67,23 @@ function setup(overrides) {
|
||||
columns,
|
||||
savedMetrics: [savedMetric],
|
||||
formData,
|
||||
theme: supersetTheme,
|
||||
...overrides,
|
||||
};
|
||||
const wrapper = shallow(<AdhocFilterControl {...props} />);
|
||||
return { wrapper, onChange };
|
||||
const component = wrapper.dive().dive().shallow();
|
||||
return { wrapper, component, onChange };
|
||||
}
|
||||
|
||||
describe('AdhocFilterControl', () => {
|
||||
it('renders Select', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(Select)).toExist();
|
||||
it('renders LabelsContainer', () => {
|
||||
const { component } = setup();
|
||||
expect(component.find(LabelsContainer)).toExist();
|
||||
});
|
||||
|
||||
it('handles saved metrics being selected to filter on', () => {
|
||||
const { wrapper, onChange } = setup({ value: [] });
|
||||
const select = wrapper.find(Select);
|
||||
select.simulate('change', [{ saved_metric_name: 'sum__value' }]);
|
||||
const { component, onChange } = setup({ value: [] });
|
||||
component.instance().onNewFilter({ saved_metric_name: 'sum__value' });
|
||||
|
||||
const adhocFilter = onChange.lastCall.args[0][0];
|
||||
expect(adhocFilter instanceof AdhocFilter).toBe(true);
|
||||
@@ -99,9 +101,8 @@ describe('AdhocFilterControl', () => {
|
||||
});
|
||||
|
||||
it('handles adhoc metrics being selected to filter on', () => {
|
||||
const { wrapper, onChange } = setup({ value: [] });
|
||||
const select = wrapper.find(Select);
|
||||
select.simulate('change', [sumValueAdhocMetric]);
|
||||
const { component, onChange } = setup({ value: [] });
|
||||
component.instance().onNewFilter(sumValueAdhocMetric);
|
||||
|
||||
const adhocFilter = onChange.lastCall.args[0][0];
|
||||
expect(adhocFilter instanceof AdhocFilter).toBe(true);
|
||||
@@ -118,30 +119,9 @@ describe('AdhocFilterControl', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('handles columns being selected to filter on', () => {
|
||||
const { wrapper, onChange } = setup({ value: [] });
|
||||
const select = wrapper.find(Select);
|
||||
select.simulate('change', [columns[0]]);
|
||||
|
||||
const adhocFilter = onChange.lastCall.args[0][0];
|
||||
expect(adhocFilter instanceof AdhocFilter).toBe(true);
|
||||
expect(
|
||||
adhocFilter.equals(
|
||||
new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
subject: columns[0].column_name,
|
||||
operator: OPERATORS['=='],
|
||||
comparator: '',
|
||||
clause: CLAUSES.WHERE,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('persists existing filters even when new filters are added', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(Select);
|
||||
select.simulate('change', [simpleAdhocFilter, columns[0]]);
|
||||
const { component, onChange } = setup();
|
||||
component.instance().onNewFilter(columns[0]);
|
||||
|
||||
const existingAdhocFilter = onChange.lastCall.args[0][0];
|
||||
expect(existingAdhocFilter instanceof AdhocFilter).toBe(true);
|
||||
|
||||
@@ -41,7 +41,7 @@ const simpleAdhocFilter = new AdhocFilter({
|
||||
const simpleMultiAdhocFilter = new AdhocFilter({
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
subject: 'value',
|
||||
operator: 'in',
|
||||
operator: 'IN',
|
||||
comparator: ['10'],
|
||||
clause: CLAUSES.WHERE,
|
||||
});
|
||||
@@ -112,10 +112,10 @@ describe('AdhocFilterEditPopoverSimpleTabContent', () => {
|
||||
|
||||
it('will convert from individual comparator to array if the operator changes to multi', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
wrapper.instance().onOperatorChange('in');
|
||||
wrapper.instance().onOperatorChange('IN');
|
||||
expect(onChange.calledOnce).toBe(true);
|
||||
expect(onChange.lastCall.args[0]).toEqual(
|
||||
simpleAdhocFilter.duplicateWith({ operator: 'in', comparator: ['10'] }),
|
||||
simpleAdhocFilter.duplicateWith({ operator: 'IN', comparator: ['10'] }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -141,13 +141,13 @@ describe('AdhocFilterEditPopoverSimpleTabContent', () => {
|
||||
|
||||
it('will filter operators for table datasources', () => {
|
||||
const { wrapper } = setup({ datasource: { type: 'table' } });
|
||||
expect(wrapper.instance().isOperatorRelevant('regex')).toBe(false);
|
||||
expect(wrapper.instance().isOperatorRelevant('REGEX')).toBe(false);
|
||||
expect(wrapper.instance().isOperatorRelevant('LIKE')).toBe(true);
|
||||
});
|
||||
|
||||
it('will filter operators for druid datasources', () => {
|
||||
const { wrapper } = setup({ datasource: { type: 'druid' } });
|
||||
expect(wrapper.instance().isOperatorRelevant('regex')).toBe(true);
|
||||
expect(wrapper.instance().isOperatorRelevant('REGEX')).toBe(true);
|
||||
expect(wrapper.instance().isOperatorRelevant('LIKE')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('AdhocFilterEditPopoverSimpleTabContent', () => {
|
||||
comparator: null,
|
||||
clause: 'WHERE',
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: "ds = '{{ presto.latest_partition('schema.table1') }}' ",
|
||||
sqlExpression: "ds = '{{ presto.latest_partition('schema.table1') }}'",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import sinon from 'sinon';
|
||||
import { shallow } from 'enzyme';
|
||||
import Popover from 'src/common/components/Popover';
|
||||
|
||||
import Label from 'src/components/Label';
|
||||
import AdhocFilter, {
|
||||
EXPRESSION_TYPES,
|
||||
CLAUSES,
|
||||
@@ -53,15 +52,10 @@ function setup(overrides) {
|
||||
describe('AdhocFilterOption', () => {
|
||||
it('renders an overlay trigger wrapper for the label', () => {
|
||||
const { wrapper } = setup();
|
||||
const overlay = wrapper.find(Popover);
|
||||
expect(overlay).toHaveLength(1);
|
||||
expect(overlay.props().defaultVisible).toBe(false);
|
||||
expect(wrapper.find(Label)).toExist();
|
||||
});
|
||||
it('should open new filter popup by default', () => {
|
||||
const { wrapper } = setup({
|
||||
adhocFilter: simpleAdhocFilter.duplicateWith({ isNew: true }),
|
||||
});
|
||||
expect(wrapper.find(Popover).props().defaultVisible).toBe(true);
|
||||
const overlay = wrapper.find('AdhocFilterPopoverTrigger').shallow();
|
||||
const popover = overlay.find(Popover);
|
||||
expect(popover).toHaveLength(1);
|
||||
expect(popover.props().defaultVisible).toBe(false);
|
||||
expect(overlay.find('DraggableOptionControlLabel')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,8 @@ function setup(overrides) {
|
||||
const onClose = sinon.spy();
|
||||
const props = {
|
||||
adhocMetric: sumValueAdhocMetric,
|
||||
savedMetric: {},
|
||||
savedMetrics: [],
|
||||
onChange,
|
||||
onClose,
|
||||
onResize: () => {},
|
||||
@@ -62,7 +64,7 @@ function setup(overrides) {
|
||||
describe('AdhocMetricEditPopover', () => {
|
||||
it('renders a popover with edit metric form contents', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(FormGroup)).toHaveLength(3);
|
||||
expect(wrapper.find(FormGroup)).toHaveLength(4);
|
||||
expect(wrapper.find(Button)).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import sinon from 'sinon';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import Popover from 'src/common/components/Popover';
|
||||
import Label from 'src/components/Label';
|
||||
import AdhocMetric from 'src/explore/AdhocMetric';
|
||||
import AdhocMetricOption from 'src/explore/components/AdhocMetricOption';
|
||||
import { AGGREGATES } from 'src/explore/constants';
|
||||
@@ -42,11 +41,18 @@ function setup(overrides) {
|
||||
const onMetricEdit = sinon.spy();
|
||||
const props = {
|
||||
adhocMetric: sumValueAdhocMetric,
|
||||
savedMetric: {},
|
||||
savedMetrics: [],
|
||||
onMetricEdit,
|
||||
columns,
|
||||
onMoveLabel: () => {},
|
||||
onDropLabel: () => {},
|
||||
index: 0,
|
||||
...overrides,
|
||||
};
|
||||
const wrapper = shallow(<AdhocMetricOption {...props} />);
|
||||
const wrapper = shallow(<AdhocMetricOption {...props} />)
|
||||
.find('AdhocMetricPopoverTrigger')
|
||||
.shallow();
|
||||
return { wrapper, onMetricEdit };
|
||||
}
|
||||
|
||||
@@ -54,14 +60,7 @@ describe('AdhocMetricOption', () => {
|
||||
it('renders an overlay trigger wrapper for the label', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(Popover)).toExist();
|
||||
expect(wrapper.find(Label)).toExist();
|
||||
});
|
||||
|
||||
it('overlay should open if metric is new', () => {
|
||||
const { wrapper } = setup({
|
||||
adhocMetric: sumValueAdhocMetric.duplicateWith({ isNew: true }),
|
||||
});
|
||||
expect(wrapper.find(Popover).props().defaultVisible).toBe(true);
|
||||
expect(wrapper.find('DraggableOptionControlLabel')).toExist();
|
||||
});
|
||||
|
||||
it('overwrites the adhocMetric in state with onLabelChange', () => {
|
||||
|
||||
@@ -91,6 +91,6 @@ describe('ControlPanelsContainer', () => {
|
||||
|
||||
it('renders ControlPanelSections', () => {
|
||||
wrapper = shallow(<ControlPanelsContainer {...getDefaultProps()} />);
|
||||
expect(wrapper.find(ControlPanelSection)).toHaveLength(6);
|
||||
expect(wrapper.find(ControlPanelSection)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
/* eslint-disable no-unused-expressions */
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { MetricOption } from '@superset-ui/chart-controls';
|
||||
|
||||
import MetricDefinitionValue from 'src/explore/components/MetricDefinitionValue';
|
||||
import AdhocMetricOption from 'src/explore/components/AdhocMetricOption';
|
||||
@@ -36,7 +35,7 @@ describe('MetricDefinitionValue', () => {
|
||||
const wrapper = shallow(
|
||||
<MetricDefinitionValue option={{ metric_name: 'a_saved_metric' }} />,
|
||||
);
|
||||
expect(wrapper.find(MetricOption)).toExist();
|
||||
expect(wrapper.find('AdhocMetricOption')).toExist();
|
||||
});
|
||||
|
||||
it('renders an AdhocMetricOption given an adhoc metric', () => {
|
||||
|
||||
@@ -23,8 +23,9 @@ import { shallow } from 'enzyme';
|
||||
|
||||
import MetricsControl from 'src/explore/components/controls/MetricsControl';
|
||||
import { AGGREGATES } from 'src/explore/constants';
|
||||
import Select from 'src/components/Select';
|
||||
import AdhocMetric, { EXPRESSION_TYPES } from 'src/explore/AdhocMetric';
|
||||
import { LabelsContainer } from 'src/explore/components/OptionControls';
|
||||
import { supersetTheme } from '@superset-ui/core';
|
||||
|
||||
const defaultProps = {
|
||||
name: 'metrics',
|
||||
@@ -47,11 +48,13 @@ function setup(overrides) {
|
||||
const onChange = sinon.spy();
|
||||
const props = {
|
||||
onChange,
|
||||
theme: supersetTheme,
|
||||
...defaultProps,
|
||||
...overrides,
|
||||
};
|
||||
const wrapper = shallow(<MetricsControl {...props} />);
|
||||
return { wrapper, onChange };
|
||||
const component = wrapper.dive().dive().shallow();
|
||||
return { wrapper, component, onChange };
|
||||
}
|
||||
|
||||
const valueColumn = { type: 'DOUBLE', column_name: 'value' };
|
||||
@@ -64,14 +67,14 @@ const sumValueAdhocMetric = new AdhocMetric({
|
||||
|
||||
describe('MetricsControl', () => {
|
||||
it('renders Select', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.find(Select)).toExist();
|
||||
const { component } = setup();
|
||||
expect(component.find(LabelsContainer)).toExist();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('unifies options for the dropdown select with aggregates', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.state('options')).toEqual([
|
||||
const { component } = setup();
|
||||
expect(component.state('options')).toEqual([
|
||||
{
|
||||
optionName: '_col_source',
|
||||
type: 'VARCHAR(255)',
|
||||
@@ -101,8 +104,8 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('does not show aggregates in options if no columns', () => {
|
||||
const { wrapper } = setup({ columns: [] });
|
||||
expect(wrapper.state('options')).toEqual([
|
||||
const { component } = setup({ columns: [] });
|
||||
expect(component.state('options')).toEqual([
|
||||
{
|
||||
optionName: 'sum__value',
|
||||
metric_name: 'sum__value',
|
||||
@@ -117,7 +120,7 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('coerces Adhoc Metrics from form data into instances of the AdhocMetric class and leaves saved metrics', () => {
|
||||
const { wrapper } = setup({
|
||||
const { component } = setup({
|
||||
value: [
|
||||
{
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
@@ -130,10 +133,10 @@ describe('MetricsControl', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const adhocMetric = wrapper.state('value')[0];
|
||||
const adhocMetric = component.state('value')[0];
|
||||
expect(adhocMetric instanceof AdhocMetric).toBe(true);
|
||||
expect(adhocMetric.optionName.length).toBeGreaterThan(10);
|
||||
expect(wrapper.state('value')).toEqual([
|
||||
expect(component.state('value')).toEqual([
|
||||
{
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
column: { type: 'double', column_name: 'value' },
|
||||
@@ -150,97 +153,23 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
it('handles saved metrics being selected', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(Select);
|
||||
select.simulate('change', [{ metric_name: 'sum__value' }]);
|
||||
it('handles creating a new metric', () => {
|
||||
const { component, onChange } = setup();
|
||||
component.instance().onNewMetric({ metric_name: 'sum__value' });
|
||||
expect(onChange.lastCall.args).toEqual([['sum__value']]);
|
||||
});
|
||||
|
||||
it('handles columns being selected', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(Select);
|
||||
select.simulate('change', [valueColumn]);
|
||||
|
||||
const adhocMetric = onChange.lastCall.args[0][0];
|
||||
expect(adhocMetric).toBeInstanceOf(AdhocMetric);
|
||||
expect(adhocMetric.isNew).toBe(true);
|
||||
expect(onChange.lastCall.args).toEqual([
|
||||
[
|
||||
{
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.SUM,
|
||||
label: 'SUM(value)',
|
||||
hasCustomLabel: false,
|
||||
optionName: adhocMetric.optionName,
|
||||
sqlExpression: null,
|
||||
isNew: true,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles aggregates being selected', () => {
|
||||
return new Promise(done => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(Select);
|
||||
|
||||
// mock out the Select ref
|
||||
const instance = wrapper.instance();
|
||||
const handleInputChangeSpy = jest.fn();
|
||||
const focusInputSpy = jest.fn();
|
||||
// simulate react-select StateManager
|
||||
instance.selectRef({
|
||||
select: {
|
||||
handleInputChange: handleInputChangeSpy,
|
||||
inputRef: { value: '' },
|
||||
focusInput: focusInputSpy,
|
||||
},
|
||||
});
|
||||
|
||||
select.simulate('change', [
|
||||
{ aggregate_name: 'SUM', optionName: 'SUM' },
|
||||
]);
|
||||
|
||||
expect(instance.select.inputRef.value).toBe('SUM()');
|
||||
expect(handleInputChangeSpy).toHaveBeenCalledWith({
|
||||
currentTarget: { value: 'SUM()' },
|
||||
});
|
||||
expect(onChange.calledOnceWith([])).toBe(true);
|
||||
expect(focusInputSpy).toHaveBeenCalledTimes(0);
|
||||
setTimeout(() => {
|
||||
expect(focusInputSpy).toHaveBeenCalledTimes(1);
|
||||
expect(instance.select.inputRef.selectionStart).toBe(4);
|
||||
expect(instance.select.inputRef.selectionEnd).toBe(4);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing selected AdhocMetrics', () => {
|
||||
const { wrapper, onChange } = setup();
|
||||
const select = wrapper.find(Select);
|
||||
select.simulate('change', [
|
||||
{ metric_name: 'sum__value' },
|
||||
sumValueAdhocMetric,
|
||||
]);
|
||||
expect(onChange.lastCall.args).toEqual([
|
||||
['sum__value', sumValueAdhocMetric],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMetricEdit', () => {
|
||||
it('accepts an edited metric from an AdhocMetricEditPopover', () => {
|
||||
const { wrapper, onChange } = setup({
|
||||
const { component, onChange } = setup({
|
||||
value: [sumValueAdhocMetric],
|
||||
});
|
||||
|
||||
const editedMetric = sumValueAdhocMetric.duplicateWith({
|
||||
aggregate: AGGREGATES.AVG,
|
||||
});
|
||||
wrapper.instance().onMetricEdit(editedMetric);
|
||||
component.instance().onMetricEdit(editedMetric, sumValueAdhocMetric);
|
||||
|
||||
expect(onChange.lastCall.args).toEqual([[editedMetric]]);
|
||||
});
|
||||
@@ -248,40 +177,28 @@ describe('MetricsControl', () => {
|
||||
|
||||
describe('checkIfAggregateInInput', () => {
|
||||
it('handles an aggregate in the input', () => {
|
||||
const { wrapper } = setup();
|
||||
const { component } = setup();
|
||||
|
||||
expect(wrapper.state('aggregateInInput')).toBeNull();
|
||||
wrapper.instance().checkIfAggregateInInput('AVG(');
|
||||
expect(wrapper.state('aggregateInInput')).toBe(AGGREGATES.AVG);
|
||||
expect(component.state('aggregateInInput')).toBeNull();
|
||||
component.instance().checkIfAggregateInInput('AVG(');
|
||||
expect(component.state('aggregateInInput')).toBe(AGGREGATES.AVG);
|
||||
});
|
||||
|
||||
it('handles no aggregate in the input', () => {
|
||||
const { wrapper } = setup();
|
||||
const { component } = setup();
|
||||
|
||||
expect(wrapper.state('aggregateInInput')).toBeNull();
|
||||
wrapper.instance().checkIfAggregateInInput('colu');
|
||||
expect(wrapper.state('aggregateInInput')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles an aggregate in the input when paste event fires', () => {
|
||||
const { wrapper } = setup();
|
||||
expect(wrapper.state('aggregateInInput')).toBeNull();
|
||||
|
||||
const mEvent = {
|
||||
clipboardData: { getData: jest.fn().mockReturnValueOnce('AVG(') },
|
||||
};
|
||||
const select = wrapper.find(Select);
|
||||
select.simulate('paste', mEvent);
|
||||
expect(wrapper.state('aggregateInInput')).toBe(AGGREGATES.AVG);
|
||||
expect(component.state('aggregateInInput')).toBeNull();
|
||||
component.instance().checkIfAggregateInInput('colu');
|
||||
expect(component.state('aggregateInInput')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('option filter', () => {
|
||||
it('includes user defined metrics', () => {
|
||||
const { wrapper } = setup({ datasourceType: 'druid' });
|
||||
const { component } = setup({ datasourceType: 'druid' });
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'a_metric',
|
||||
@@ -295,10 +212,10 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('includes auto generated avg metrics for druid', () => {
|
||||
const { wrapper } = setup({ datasourceType: 'druid' });
|
||||
const { component } = setup({ datasourceType: 'druid' });
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'avg__metric',
|
||||
@@ -312,10 +229,10 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('includes columns and aggregates', () => {
|
||||
const { wrapper } = setup();
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
type: 'VARCHAR(255)',
|
||||
@@ -328,7 +245,7 @@ describe('MetricsControl', () => {
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
!!wrapper
|
||||
!!component
|
||||
.instance()
|
||||
.selectFilterOption(
|
||||
{ data: { aggregate_name: 'AVG', optionName: '_aggregate_AVG' } },
|
||||
@@ -338,10 +255,10 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('includes columns based on verbose_name', () => {
|
||||
const { wrapper } = setup();
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'sum__num',
|
||||
@@ -355,10 +272,10 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('excludes auto generated avg metrics for sqla', () => {
|
||||
const { wrapper } = setup();
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'avg__metric',
|
||||
@@ -372,10 +289,10 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('includes custom made simple saved metrics', () => {
|
||||
const { wrapper } = setup();
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'my_fancy_sum_metric',
|
||||
@@ -389,10 +306,10 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('excludes auto generated metrics', () => {
|
||||
const { wrapper } = setup();
|
||||
const { component } = setup();
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'sum__value',
|
||||
@@ -405,7 +322,7 @@ describe('MetricsControl', () => {
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: {
|
||||
metric_name: 'sum__value',
|
||||
@@ -419,11 +336,11 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('filters out metrics if the input begins with an aggregate', () => {
|
||||
const { wrapper } = setup();
|
||||
wrapper.setState({ aggregateInInput: true });
|
||||
const { component } = setup();
|
||||
component.setState({ aggregateInInput: true });
|
||||
|
||||
expect(
|
||||
!!wrapper.instance().selectFilterOption(
|
||||
!!component.instance().selectFilterOption(
|
||||
{
|
||||
data: { metric_name: 'metric', expression: 'SUM(FANCY(metric))' },
|
||||
},
|
||||
@@ -433,11 +350,11 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('includes columns if the input begins with an aggregate', () => {
|
||||
const { wrapper } = setup();
|
||||
wrapper.setState({ aggregateInInput: true });
|
||||
const { component } = setup();
|
||||
component.setState({ aggregateInInput: true });
|
||||
|
||||
expect(
|
||||
!!wrapper
|
||||
!!component
|
||||
.instance()
|
||||
.selectFilterOption(
|
||||
{ data: { type: 'DOUBLE', column_name: 'value' } },
|
||||
@@ -447,7 +364,7 @@ describe('MetricsControl', () => {
|
||||
});
|
||||
|
||||
it('Removes metrics if savedMetrics changes', () => {
|
||||
const { props, wrapper, onChange } = setup({
|
||||
const { props, component, onChange } = setup({
|
||||
value: [
|
||||
{
|
||||
expressionType: EXPRESSION_TYPES.SIMPLE,
|
||||
@@ -458,14 +375,14 @@ describe('MetricsControl', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(wrapper.state('value')).toHaveLength(1);
|
||||
expect(component.state('value')).toHaveLength(1);
|
||||
|
||||
wrapper.setProps({ ...props, columns: [] });
|
||||
component.setProps({ ...props, columns: [] });
|
||||
expect(onChange.lastCall.args).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('Does not remove custom sql metric if savedMetrics changes', () => {
|
||||
const { props, wrapper, onChange } = setup({
|
||||
const { props, component, onChange } = setup({
|
||||
value: [
|
||||
{
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
@@ -475,17 +392,17 @@ describe('MetricsControl', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(wrapper.state('value')).toHaveLength(1);
|
||||
expect(component.state('value')).toHaveLength(1);
|
||||
|
||||
wrapper.setProps({ ...props, columns: [] });
|
||||
component.setProps({ ...props, columns: [] });
|
||||
expect(onChange.calledOnce).toEqual(false);
|
||||
});
|
||||
it('Does not fail if no columns or savedMetrics are passed', () => {
|
||||
const { wrapper } = setup({
|
||||
const { component } = setup({
|
||||
savedMetrics: null,
|
||||
columns: null,
|
||||
});
|
||||
expect(wrapper.exists('.metrics-select')).toEqual(true);
|
||||
expect(component.exists('.metrics-select')).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,9 @@ describe('VerifiedMetricsControl', () => {
|
||||
onChange: mockOnChange,
|
||||
});
|
||||
|
||||
const child = wrapper.find(MetricsControl);
|
||||
const child = wrapper.find(MetricsControl) as ReactWrapper<{
|
||||
onChange: (str: string[]) => void;
|
||||
}>;
|
||||
child.props().onChange(['abc']);
|
||||
|
||||
expect(child.length).toBe(1);
|
||||
|
||||
@@ -21,9 +21,7 @@ import { getChartControlPanelRegistry, t } from '@superset-ui/core';
|
||||
import {
|
||||
getControlConfig,
|
||||
getControlState,
|
||||
getFormDataFromControls,
|
||||
applyMapStateToPropsToControl,
|
||||
getAllControlsState,
|
||||
findControlItem,
|
||||
} from 'src/explore/controlUtils';
|
||||
import {
|
||||
@@ -198,18 +196,6 @@ describe('controlUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryFields', () => {
|
||||
it('in formData', () => {
|
||||
const controlsState = getAllControlsState('table', 'table', {}, {});
|
||||
const formData = getFormDataFromControls(controlsState);
|
||||
expect(formData.queryFields).toEqual({
|
||||
all_columns: 'columns',
|
||||
metric: 'metrics',
|
||||
metrics: 'metrics',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findControlItem', () => {
|
||||
it('find control as a string', () => {
|
||||
const controlItem = findControlItem(
|
||||
|
||||
@@ -83,7 +83,6 @@ export const controlPanelSectionsChartOptionsTable = [
|
||||
name: 'all_columns',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
queryField: 'columns',
|
||||
multi: true,
|
||||
label: t('Columns'),
|
||||
default: [],
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getExploreLongUrl,
|
||||
getDataTablePageSize,
|
||||
shouldUseLegacyApi,
|
||||
getSimpleSQLExpression,
|
||||
} from 'src/explore/exploreUtils';
|
||||
import {
|
||||
buildTimeRangeString,
|
||||
@@ -298,4 +299,31 @@ describe('exploreUtils', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSimpleSQLExpression', () => {
|
||||
const subject = 'subject';
|
||||
const operator = '=';
|
||||
const comparator = 'comparator';
|
||||
it('returns empty string when subject is undefined', () => {
|
||||
expect(getSimpleSQLExpression(undefined, '=', 10)).toBe('');
|
||||
expect(getSimpleSQLExpression()).toBe('');
|
||||
});
|
||||
it('returns subject when its provided and operator is undefined', () => {
|
||||
expect(getSimpleSQLExpression(subject, undefined, 10)).toBe(subject);
|
||||
expect(getSimpleSQLExpression(subject)).toBe(subject);
|
||||
});
|
||||
it('returns subject and operator when theyre provided and comparator is undefined', () => {
|
||||
expect(getSimpleSQLExpression(subject, operator)).toBe(
|
||||
`${subject} ${operator}`,
|
||||
);
|
||||
});
|
||||
it('returns full expression when subject, operator and comparator are provided', () => {
|
||||
expect(getSimpleSQLExpression(subject, operator, comparator)).toBe(
|
||||
`${subject} ${operator} ${comparator}`,
|
||||
);
|
||||
expect(getSimpleSQLExpression(subject, operator, comparator, true)).toBe(
|
||||
`${subject} ${operator} ('${comparator}')`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"no-prototype-builtins": 2,
|
||||
"class-methods-use-this": 2,
|
||||
"import/no-named-as-default": 2,
|
||||
"import/prefer-default-export": 2,
|
||||
"react/no-unescaped-entities": 2,
|
||||
"react/no-string-refs": 2,
|
||||
"react/jsx-indent": 0,
|
||||
|
||||
@@ -20,7 +20,7 @@ import React from 'react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { styledShallow as shallow } from 'spec/helpers/theming';
|
||||
import SouthPaneContainer, { SouthPane } from 'src/SqlLab/components/SouthPane';
|
||||
import SouthPaneContainer from 'src/SqlLab/components/SouthPane';
|
||||
import ResultSet from 'src/SqlLab/components/ResultSet';
|
||||
import { STATUS_OPTIONS } from 'src/SqlLab/constants';
|
||||
import { initialState } from './fixtures';
|
||||
@@ -80,12 +80,6 @@ describe('SouthPane', () => {
|
||||
|
||||
let wrapper;
|
||||
|
||||
beforeAll(() => {
|
||||
jest
|
||||
.spyOn(SouthPane.prototype, 'getSouthPaneHeight')
|
||||
.mockImplementation(() => 500);
|
||||
});
|
||||
|
||||
it('should render offline when the state is offline', () => {
|
||||
wrapper = getWrapper();
|
||||
wrapper.setProps({ offline: true });
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 { cacheWrapper } from 'src/utils/cacheWrapper';
|
||||
|
||||
describe('cacheWrapper', () => {
|
||||
const fnResult = 'fnResult';
|
||||
const fn = jest.fn<string, [number, number]>().mockReturnValue(fnResult);
|
||||
|
||||
let wrappedFn: (a: number, b: number) => string;
|
||||
|
||||
beforeEach(() => {
|
||||
const cache = new Map<string, any>();
|
||||
wrappedFn = cacheWrapper(fn, cache);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls fn with its arguments once when the key is not found', () => {
|
||||
const returnedValue = wrappedFn(1, 2);
|
||||
|
||||
expect(returnedValue).toEqual(fnResult);
|
||||
expect(fn).toBeCalledTimes(1);
|
||||
expect(fn).toBeCalledWith(1, 2);
|
||||
});
|
||||
|
||||
describe('subsequent calls', () => {
|
||||
it('returns the correct value without fn being called multiple times', () => {
|
||||
const returnedValue1 = wrappedFn(1, 2);
|
||||
const returnedValue2 = wrappedFn(1, 2);
|
||||
|
||||
expect(returnedValue1).toEqual(fnResult);
|
||||
expect(returnedValue2).toEqual(fnResult);
|
||||
expect(fn).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fn is called multiple times for different arguments', () => {
|
||||
wrappedFn(1, 2);
|
||||
wrappedFn(1, 3);
|
||||
|
||||
expect(fn).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with custom keyFn', () => {
|
||||
let cache: Map<string, any>;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new Map<string, any>();
|
||||
wrappedFn = cacheWrapper(fn, cache, (...args) => `key-${args[0]}`);
|
||||
});
|
||||
|
||||
it('saves fn result in cache under generated key', () => {
|
||||
wrappedFn(1, 2);
|
||||
expect(cache.get('key-1')).toEqual(fnResult);
|
||||
});
|
||||
|
||||
it('subsequent calls with same generated key calls fn once, even if other arguments have changed', () => {
|
||||
wrappedFn(1, 1);
|
||||
wrappedFn(1, 2);
|
||||
wrappedFn(1, 3);
|
||||
|
||||
expect(fn).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,26 +62,10 @@ const defaultProps = {
|
||||
export class SouthPane extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
height: props.height,
|
||||
};
|
||||
this.southPaneRef = React.createRef();
|
||||
this.getSouthPaneHeight = this.getSouthPaneHeight.bind(this);
|
||||
this.switchTab = this.switchTab.bind(this);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps() {
|
||||
// south pane expands the entire height of the tab content on mount
|
||||
this.setState({ height: this.getSouthPaneHeight() });
|
||||
}
|
||||
|
||||
// One layer of abstraction for easy spying in unit tests
|
||||
getSouthPaneHeight() {
|
||||
return this.southPaneRef.current
|
||||
? this.southPaneRef.current.clientHeight
|
||||
: 0;
|
||||
}
|
||||
|
||||
switchTab(id) {
|
||||
this.props.actions.setActiveSouthPaneTab(id);
|
||||
}
|
||||
@@ -97,7 +81,7 @@ export class SouthPane extends React.PureComponent {
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
const innerTabContentHeight = this.state.height - TAB_HEIGHT;
|
||||
const innerTabContentHeight = this.props.height - TAB_HEIGHT;
|
||||
let latestQuery;
|
||||
const { props } = this;
|
||||
if (props.editorQueries.length > 0) {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@import '../../stylesheets/less/variables.less';
|
||||
|
||||
body {
|
||||
min-height: 500px; // Set a min height so the gutter is always visible when resizing
|
||||
min-height: ~'max(100vh, 500px)'; // Set a min height so the gutter is always visible when resizing
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const propTypes = {
|
||||
chartAlert: PropTypes.string,
|
||||
chartStatus: PropTypes.string,
|
||||
chartStackTrace: PropTypes.string,
|
||||
queryResponse: PropTypes.object,
|
||||
queriesResponse: PropTypes.arrayOf(PropTypes.object),
|
||||
triggerQuery: PropTypes.bool,
|
||||
refreshOverlayVisible: PropTypes.bool,
|
||||
errorMessage: PropTypes.node,
|
||||
@@ -150,14 +150,8 @@ class Chart extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
renderErrorMessage() {
|
||||
const {
|
||||
chartAlert,
|
||||
chartStackTrace,
|
||||
dashboardId,
|
||||
owners,
|
||||
queryResponse,
|
||||
} = this.props;
|
||||
renderErrorMessage(queryResponse) {
|
||||
const { chartAlert, chartStackTrace, dashboardId, owners } = this.props;
|
||||
|
||||
const error = queryResponse?.errors?.[0];
|
||||
if (error) {
|
||||
@@ -187,14 +181,14 @@ class Chart extends React.PureComponent {
|
||||
errorMessage,
|
||||
onQuery,
|
||||
refreshOverlayVisible,
|
||||
queriesResponse = [],
|
||||
} = this.props;
|
||||
|
||||
const isLoading = chartStatus === 'loading';
|
||||
|
||||
const isFaded = refreshOverlayVisible && !errorMessage;
|
||||
this.renderContainerStartTime = Logger.getTimestamp();
|
||||
if (chartStatus === 'failed') {
|
||||
return this.renderErrorMessage();
|
||||
return queriesResponse.map(item => this.renderErrorMessage(item));
|
||||
}
|
||||
if (errorMessage) {
|
||||
return (
|
||||
|
||||
@@ -37,7 +37,7 @@ const propTypes = {
|
||||
// state
|
||||
chartAlert: PropTypes.string,
|
||||
chartStatus: PropTypes.string,
|
||||
queryResponse: PropTypes.object,
|
||||
queriesResponse: PropTypes.arrayOf(PropTypes.object),
|
||||
triggerQuery: PropTypes.bool,
|
||||
refreshOverlayVisible: PropTypes.bool,
|
||||
// dashboard callbacks
|
||||
@@ -78,14 +78,14 @@ class ChartRenderer extends React.Component {
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const resultsReady =
|
||||
nextProps.queryResponse &&
|
||||
nextProps.queriesResponse &&
|
||||
['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 &&
|
||||
!nextProps.queryResponse.error &&
|
||||
!nextProps.queriesResponse?.[0]?.error &&
|
||||
!nextProps.refreshOverlayVisible;
|
||||
|
||||
if (resultsReady) {
|
||||
this.hasQueryResponseChange =
|
||||
nextProps.queryResponse !== this.props.queryResponse;
|
||||
nextProps.queriesResponse !== this.props.queriesResponse;
|
||||
return (
|
||||
this.hasQueryResponseChange ||
|
||||
nextProps.annotationData !== this.props.annotationData ||
|
||||
@@ -179,7 +179,7 @@ class ChartRenderer extends React.Component {
|
||||
datasource,
|
||||
initialValues,
|
||||
formData,
|
||||
queryResponse,
|
||||
queriesResponse,
|
||||
} = this.props;
|
||||
|
||||
// It's bad practice to use unprefixed `vizType` as classnames for chart
|
||||
@@ -218,7 +218,7 @@ class ChartRenderer extends React.Component {
|
||||
initialValues={initialValues}
|
||||
formData={formData}
|
||||
hooks={this.hooks}
|
||||
queryData={queryResponse}
|
||||
queriesData={queriesResponse}
|
||||
onRenderSuccess={this.handleRenderSuccess}
|
||||
onRenderFailure={this.handleRenderFailure}
|
||||
/>
|
||||
|
||||
@@ -52,8 +52,8 @@ export function chartUpdateStarted(queryController, latestQueryFormData, key) {
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
|
||||
export function chartUpdateSucceeded(queryResponse, key) {
|
||||
return { type: CHART_UPDATE_SUCCEEDED, queryResponse, key };
|
||||
export function chartUpdateSucceeded(queriesResponse, key) {
|
||||
return { type: CHART_UPDATE_SUCCEEDED, queriesResponse, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED';
|
||||
@@ -62,8 +62,8 @@ export function chartUpdateStopped(key) {
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
|
||||
export function chartUpdateFailed(queryResponse, key) {
|
||||
return { type: CHART_UPDATE_FAILED, queryResponse, key };
|
||||
export function chartUpdateFailed(queriesResponse, key) {
|
||||
return { type: CHART_UPDATE_FAILED, queriesResponse, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_QUEUED = 'CHART_UPDATE_QUEUED';
|
||||
@@ -361,38 +361,35 @@ export function exploreJSON(
|
||||
|
||||
const chartDataRequestCaught = chartDataRequest
|
||||
.then(response => {
|
||||
const queriesResponse = response.result;
|
||||
if (isFeatureEnabled(FeatureFlag.GLOBAL_ASYNC_QUERIES)) {
|
||||
// deal with getChartDataRequest transforming the response data
|
||||
const result = 'result' in response ? response.result[0] : response;
|
||||
return dispatch(chartUpdateQueued(result, key));
|
||||
}
|
||||
|
||||
// new API returns an object with an array of restults
|
||||
// problem: response holds a list of results, when before we were just getting one result.
|
||||
// How to make the entire app compatible with multiple results?
|
||||
// For now just use the first result.
|
||||
const result = response.result[0];
|
||||
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_LOAD_CHART, {
|
||||
slice_id: key,
|
||||
applied_filters: result.applied_filters,
|
||||
is_cached: result.is_cached,
|
||||
force_refresh: force,
|
||||
row_count: result.rowcount,
|
||||
datasource: formData.datasource,
|
||||
start_offset: logStart,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - logStart,
|
||||
has_extra_filters:
|
||||
formData.extra_filters && formData.extra_filters.length > 0,
|
||||
viz_type: formData.viz_type,
|
||||
data_age: result.is_cached
|
||||
? moment(new Date()).diff(moment.utc(result.cached_dttm))
|
||||
: null,
|
||||
}),
|
||||
queriesResponse.forEach(resultItem =>
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_LOAD_CHART, {
|
||||
slice_id: key,
|
||||
applied_filters: resultItem.applied_filters,
|
||||
is_cached: resultItem.is_cached,
|
||||
force_refresh: force,
|
||||
row_count: resultItem.rowcount,
|
||||
datasource: formData.datasource,
|
||||
start_offset: logStart,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - logStart,
|
||||
has_extra_filters:
|
||||
formData.extra_filters && formData.extra_filters.length > 0,
|
||||
viz_type: formData.viz_type,
|
||||
data_age: resultItem.is_cached
|
||||
? moment(new Date()).diff(moment.utc(resultItem.cached_dttm))
|
||||
: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return dispatch(chartUpdateSucceeded(result, key));
|
||||
return dispatch(chartUpdateSucceeded(queriesResponse, key));
|
||||
})
|
||||
.catch(response => {
|
||||
const appendErrorLog = (errorDetails, isCached) => {
|
||||
@@ -419,7 +416,7 @@ export function exploreJSON(
|
||||
} else {
|
||||
appendErrorLog(parsedResponse.error, parsedResponse.is_cached);
|
||||
}
|
||||
return dispatch(chartUpdateFailed(parsedResponse, key));
|
||||
return dispatch(chartUpdateFailed([parsedResponse], key));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const chart = {
|
||||
chartUpdateStartTime: 0,
|
||||
latestQueryFormData: {},
|
||||
queryController: null,
|
||||
queryResponse: null,
|
||||
queriesResponse: null,
|
||||
triggerQuery: true,
|
||||
lastRendered: 0,
|
||||
};
|
||||
@@ -47,8 +47,8 @@ export default function chartReducer(charts = {}, action) {
|
||||
return {
|
||||
...state,
|
||||
chartStatus: 'success',
|
||||
queryResponse: action.queryResponse,
|
||||
chartAlert: null,
|
||||
queriesResponse: action.queriesResponse,
|
||||
chartUpdateEndTime: now(),
|
||||
};
|
||||
},
|
||||
@@ -97,13 +97,13 @@ export default function chartReducer(charts = {}, action) {
|
||||
return {
|
||||
...state,
|
||||
chartStatus: 'failed',
|
||||
chartAlert: action.queryResponse
|
||||
? action.queryResponse.error
|
||||
chartAlert: action.queriesResponse
|
||||
? action.queriesResponse?.[0]?.error
|
||||
: t('Network error.'),
|
||||
chartUpdateEndTime: now(),
|
||||
queryResponse: action.queryResponse,
|
||||
chartStackTrace: action.queryResponse
|
||||
? action.queryResponse.stacktrace
|
||||
queriesResponse: action.queriesResponse,
|
||||
chartStackTrace: action.queriesResponse
|
||||
? action.queriesResponse?.[0]?.stacktrace
|
||||
: null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ interface StyledModalProps extends SupersetThemeProps {
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
export const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
${({ theme, responsive, maxWidth }) =>
|
||||
responsive &&
|
||||
css`
|
||||
@@ -105,7 +105,9 @@ const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
}
|
||||
|
||||
// styling for Tabs component
|
||||
.ant-tabs {
|
||||
// Aaron note 20-11-19: this seems to be exclusively here for the Edit Database modal.
|
||||
// TODO: remove this as it is a special case.
|
||||
.ant-tabs-top {
|
||||
margin-top: -${({ theme }) => theme.gridUnit * 4}px;
|
||||
}
|
||||
|
||||
@@ -177,6 +179,9 @@ const CustomModal = ({
|
||||
};
|
||||
CustomModal.displayName = 'Modal';
|
||||
|
||||
// TODO: in another PR, rename this to CompatabilityModal
|
||||
// and demote it as the default export.
|
||||
// We should start using AntD component interfaces going forward.
|
||||
const Modal = Object.assign(CustomModal, {
|
||||
error: BaseModal.error,
|
||||
warning: BaseModal.warning,
|
||||
|
||||
@@ -23,15 +23,18 @@ import Icon from 'src/components/Icon';
|
||||
|
||||
interface TabsProps {
|
||||
fullWidth?: boolean;
|
||||
allowOverflow?: boolean;
|
||||
}
|
||||
|
||||
const notForwardedProps = ['fullWidth'];
|
||||
const notForwardedProps = ['fullWidth', 'allowOverflow'];
|
||||
|
||||
const StyledTabs = styled(AntdTabs, {
|
||||
shouldForwardProp: prop => !notForwardedProps.includes(prop),
|
||||
})<TabsProps>`
|
||||
overflow: ${({ allowOverflow }) => (allowOverflow ? 'visible' : 'hidden')};
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
overflow: auto;
|
||||
overflow: ${({ allowOverflow }) => (allowOverflow ? 'visible' : 'auto')};
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
|
||||
@@ -323,6 +323,7 @@ export const CollapseTextLight = () => (
|
||||
</Collapse>
|
||||
);
|
||||
export function StyledCronPicker() {
|
||||
// @ts-ignore
|
||||
const inputRef = useRef<Input>(null);
|
||||
const defaultValue = '30 5 * * 1,6';
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import React from 'react';
|
||||
import { styled } from '@superset-ui/core';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Menu as AntdMenu, Dropdown, Skeleton } from 'antd';
|
||||
import { Dropdown, Menu as AntdMenu, Input as AntdInput, Skeleton } from 'antd';
|
||||
import { DropDownProps } from 'antd/lib/dropdown';
|
||||
/*
|
||||
Antd is re-exported from here so we can override components with Emotion as needed.
|
||||
@@ -32,20 +32,28 @@ export {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Form,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Typography,
|
||||
Tree,
|
||||
Popover,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
export { TreeProps } from 'antd/lib/tree';
|
||||
export { FormInstance } from 'antd/lib/form';
|
||||
|
||||
export { default as Collapse } from './Collapse';
|
||||
export { default as Badge } from './Badge';
|
||||
@@ -75,6 +83,14 @@ export const Menu = Object.assign(AntdMenu, {
|
||||
Item: MenuItem,
|
||||
});
|
||||
|
||||
export const Input = styled(AntdInput)`
|
||||
&[type='text'],
|
||||
&[type='textarea'] {
|
||||
border: 1px solid ${({ theme }) => theme.colors.secondary.light3};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const NoAnimationDropdown = (props: DropDownProps) => (
|
||||
<Dropdown
|
||||
overlayStyle={{ zIndex: 4000, animationDuration: '0s' }}
|
||||
|
||||
38
superset-frontend/src/common/hooks/useChangeEffect.ts
Normal file
38
superset-frontend/src/common/hooks/useChangeEffect.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 { useEffect } from 'react';
|
||||
import { usePrevious } from './usePrevious';
|
||||
|
||||
/**
|
||||
* Calls the callback when the value changes.
|
||||
*
|
||||
* Passes the previous and current values to the callback
|
||||
*/
|
||||
export function useChangeEffect<T>(
|
||||
value: T,
|
||||
callback: (previous: T | undefined, current: T) => void,
|
||||
) {
|
||||
const previous = usePrevious(value);
|
||||
useEffect(() => {
|
||||
if (value !== previous) {
|
||||
callback(previous, value);
|
||||
}
|
||||
}, [value, previous, callback]);
|
||||
}
|
||||
36
superset-frontend/src/common/hooks/usePrevious.ts
Normal file
36
superset-frontend/src/common/hooks/usePrevious.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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 { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Pass in a piece of state.
|
||||
* This hook returns what the value of that state was in the previous render.
|
||||
* Returns undefined (or whatever value you specify) the first time.
|
||||
*/
|
||||
export function usePrevious<T>(value: T): T | undefined;
|
||||
export function usePrevious<T, INIT>(value: T, initialValue: INIT): T | INIT;
|
||||
export function usePrevious<T>(value: T, initialValue?: any): T {
|
||||
const previous = useRef<T>(initialValue);
|
||||
useEffect(() => {
|
||||
// useEffect runs after the render completes
|
||||
previous.current = value;
|
||||
}, [value]);
|
||||
return previous.current;
|
||||
}
|
||||
@@ -19,12 +19,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEqual, isEmpty } from 'lodash';
|
||||
import ListView from 'src/components/ListView';
|
||||
import getControlsForVizType from 'src/utils/getControlsForVizType';
|
||||
import { t } from '@superset-ui/core';
|
||||
import getControlsForVizType from 'src/utils/getControlsForVizType';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import TooltipWrapper from './TooltipWrapper';
|
||||
import ModalTrigger from './ModalTrigger';
|
||||
import { safeStringify } from '../utils/safeStringify';
|
||||
import TableView from './TableView';
|
||||
|
||||
const propTypes = {
|
||||
origFormData: PropTypes.object.isRequired,
|
||||
@@ -101,30 +101,6 @@ export default class AlteredSliceTag extends React.Component {
|
||||
return diffs;
|
||||
}
|
||||
|
||||
sortData = ({ sortBy }) => {
|
||||
if (this.state.rows.length > 0 && sortBy.length > 0) {
|
||||
const { id, desc } = sortBy[0];
|
||||
this.setState(({ rows }) => ({
|
||||
rows: this.sortDataByColumn(rows, id, desc),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
sortDataByColumn(data, sortById, desc) {
|
||||
return data.sort((row1, row2) => {
|
||||
const rows = desc ? [row2, row1] : [row1, row2];
|
||||
const firstVal = rows[0][sortById];
|
||||
const secondVal = rows[1][sortById];
|
||||
if (typeof firstVal === 'string' && typeof secondVal === 'string') {
|
||||
return secondVal.localeCompare(firstVal);
|
||||
}
|
||||
if (typeof firstVal === 'undefined' || firstVal === null) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
}
|
||||
|
||||
isEqualish(val1, val2) {
|
||||
return isEqual(alterForComparison(val1), alterForComparison(val2));
|
||||
}
|
||||
@@ -187,14 +163,11 @@ export default class AlteredSliceTag extends React.Component {
|
||||
];
|
||||
|
||||
return (
|
||||
<ListView
|
||||
<TableView
|
||||
columns={columns}
|
||||
data={this.state.rows}
|
||||
count={this.state.rows.length}
|
||||
pageSize={50}
|
||||
fetchData={this.sortData}
|
||||
loading={false}
|
||||
className="table"
|
||||
className="table-condensed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ import { ReactComponent as FilterIcon } from 'images/icons/filter.svg';
|
||||
import { ReactComponent as FilterSmallIcon } from 'images/icons/filter_small.svg';
|
||||
import { ReactComponent as FolderIcon } from 'images/icons/folder.svg';
|
||||
import { ReactComponent as FullIcon } from 'images/icons/full.svg';
|
||||
import { ReactComponent as FunctionIcon } from 'images/icons/function_x.svg';
|
||||
import { ReactComponent as GearIcon } from 'images/icons/gear.svg';
|
||||
import { ReactComponent as GridIcon } from 'images/icons/grid.svg';
|
||||
import { ReactComponent as ImageIcon } from 'images/icons/image.svg';
|
||||
@@ -205,6 +206,7 @@ export type IconName =
|
||||
| 'filter-small'
|
||||
| 'folder'
|
||||
| 'full'
|
||||
| 'function'
|
||||
| 'gear'
|
||||
| 'grid'
|
||||
| 'image'
|
||||
@@ -357,6 +359,7 @@ export const iconsRegistry: Record<
|
||||
filter: FilterIcon,
|
||||
folder: FolderIcon,
|
||||
full: FullIcon,
|
||||
function: FunctionIcon,
|
||||
gear: GearIcon,
|
||||
grid: GridIcon,
|
||||
image: ImageIcon,
|
||||
|
||||
@@ -28,10 +28,10 @@ export const FilterContainer = styled.div`
|
||||
display: inline-flex;
|
||||
margin-right: 2em;
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const FilterTitle = styled.label`
|
||||
font-weight: bold;
|
||||
line-height: 27px;
|
||||
margin: 0 0.4em 0 0;
|
||||
`;
|
||||
|
||||
116
superset-frontend/src/components/SupersetResourceSelect.tsx
Normal file
116
superset-frontend/src/components/SupersetResourceSelect.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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, { useEffect } from 'react';
|
||||
import rison from 'rison';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { AsyncSelect } from 'src/components/Select';
|
||||
import {
|
||||
ClientErrorObject,
|
||||
getClientErrorObject,
|
||||
} from 'src/utils/getClientErrorObject';
|
||||
import { cacheWrapper } from 'src/utils/cacheWrapper';
|
||||
|
||||
export type Value<V> = { value: V; label: string };
|
||||
|
||||
export interface SupersetResourceSelectProps<T = unknown, V = string> {
|
||||
value?: Value<V> | null;
|
||||
initialId?: number | string;
|
||||
onChange?: (value: Value<V>) => void;
|
||||
isMulti?: boolean;
|
||||
searchColumn?: string;
|
||||
resource?: string; // e.g. "dataset", "dashboard/related/owners"
|
||||
transformItem?: (item: T) => Value<V>;
|
||||
onError: (error: ClientErrorObject) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a special-purpose select component for when you're selecting
|
||||
* items from one of the standard Superset resource APIs.
|
||||
* Such as selecting a datasource, a chart, or users.
|
||||
*
|
||||
* If you're selecting a "related" resource (such as dashboard/related/owners),
|
||||
* leave the searchColumn prop unset.
|
||||
* The api doesn't do columns on related resources for some reason.
|
||||
*
|
||||
* If you're doing anything more complex than selecting a standard resource,
|
||||
* we'll all be better off if you use AsyncSelect directly instead.
|
||||
*/
|
||||
|
||||
const localCache = new Map<string, any>();
|
||||
|
||||
const cachedSupersetGet = cacheWrapper(
|
||||
SupersetClient.get,
|
||||
localCache,
|
||||
({ endpoint }) => endpoint || '',
|
||||
);
|
||||
|
||||
export default function SupersetResourceSelect<T, V>({
|
||||
value,
|
||||
initialId,
|
||||
onChange,
|
||||
isMulti,
|
||||
resource,
|
||||
searchColumn,
|
||||
transformItem,
|
||||
onError,
|
||||
}: SupersetResourceSelectProps<T, V>) {
|
||||
useEffect(() => {
|
||||
if (initialId == null) return;
|
||||
cachedSupersetGet({
|
||||
endpoint: `/api/v1/${resource}/${initialId}`,
|
||||
}).then(response => {
|
||||
const { result } = response.json;
|
||||
const value = transformItem ? transformItem(result) : result;
|
||||
if (onChange) onChange(value);
|
||||
});
|
||||
}, [resource, initialId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function loadOptions(input: string) {
|
||||
const query = searchColumn
|
||||
? rison.encode({
|
||||
filters: [{ col: searchColumn, opr: 'ct', value: input }],
|
||||
})
|
||||
: rison.encode({ filter: value });
|
||||
return cachedSupersetGet({
|
||||
endpoint: `/api/v1/${resource}/?q=${query}`,
|
||||
}).then(
|
||||
response => {
|
||||
return response.json.result
|
||||
.map(transformItem)
|
||||
.sort((a: Value<V>, b: Value<V>) => a.label.localeCompare(b.label));
|
||||
},
|
||||
async badResponse => {
|
||||
onError(await getClientErrorObject(badResponse));
|
||||
return [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isMulti={isMulti}
|
||||
loadOptions={loadOptions}
|
||||
defaultOptions // load options on render
|
||||
cacheOptions
|
||||
filterOption={null} // options are filtered at the api
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,3 +19,6 @@
|
||||
export const DATETIME_WITH_TIME_ZONE = 'YYYY-MM-DD HH:mm:ssZ';
|
||||
|
||||
export const TIME_WITH_MS = 'HH:mm:ss.SSS';
|
||||
|
||||
export const BOOL_TRUE_DISPLAY = 'True';
|
||||
export const BOOL_FALSE_DISPLAY = 'False';
|
||||
|
||||
137
superset-frontend/src/dashboard/actions/nativeFilters.ts
Normal file
137
superset-frontend/src/dashboard/actions/nativeFilters.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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 { ExtraFormData, makeApi } from '@superset-ui/core';
|
||||
import { Dispatch } from 'redux';
|
||||
import {
|
||||
Filter,
|
||||
FilterConfiguration,
|
||||
SelectedValues,
|
||||
} from 'src/dashboard/components/nativeFilters/types';
|
||||
import { dashboardInfoChanged } from './dashboardInfo';
|
||||
|
||||
export const SET_FILTER_CONFIG_BEGIN = 'SET_FILTER_CONFIG_BEGIN';
|
||||
export interface SetFilterConfigBegin {
|
||||
type: typeof SET_FILTER_CONFIG_BEGIN;
|
||||
filterConfig: FilterConfiguration;
|
||||
}
|
||||
export const SET_FILTER_CONFIG_COMPLETE = 'SET_FILTER_CONFIG_COMPLETE';
|
||||
export interface SetFilterConfigComplete {
|
||||
type: typeof SET_FILTER_CONFIG_COMPLETE;
|
||||
filterConfig: FilterConfiguration;
|
||||
}
|
||||
export const SET_FILTER_CONFIG_FAIL = 'SET_FILTER_CONFIG_FAIL';
|
||||
export interface SetFilterConfigFail {
|
||||
type: typeof SET_FILTER_CONFIG_FAIL;
|
||||
filterConfig: FilterConfiguration;
|
||||
}
|
||||
|
||||
export const SET_FILTER_STATE = 'SET_FILTER_STATE';
|
||||
export interface SetFilterState {
|
||||
type: typeof SET_FILTER_STATE;
|
||||
selectedValues: SelectedValues;
|
||||
filter: Filter;
|
||||
filters: FilterConfiguration;
|
||||
}
|
||||
|
||||
interface DashboardInfo {
|
||||
id: number;
|
||||
json_metadata: string;
|
||||
}
|
||||
|
||||
export const setFilterConfiguration = (
|
||||
filterConfig: FilterConfiguration,
|
||||
) => async (dispatch: Dispatch, getState: () => any) => {
|
||||
dispatch({
|
||||
type: SET_FILTER_CONFIG_BEGIN,
|
||||
filterConfig,
|
||||
});
|
||||
const { id, metadata } = getState().dashboardInfo;
|
||||
|
||||
// TODO extract this out when makeApi supports url parameters
|
||||
const updateDashboard = makeApi<
|
||||
Partial<DashboardInfo>,
|
||||
{ result: DashboardInfo }
|
||||
>({
|
||||
method: 'PUT',
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await updateDashboard({
|
||||
json_metadata: JSON.stringify({
|
||||
...metadata,
|
||||
filter_configuration: filterConfig,
|
||||
}),
|
||||
});
|
||||
dispatch(
|
||||
dashboardInfoChanged({
|
||||
metadata: JSON.parse(response.result.json_metadata),
|
||||
}),
|
||||
);
|
||||
dispatch({
|
||||
type: SET_FILTER_CONFIG_COMPLETE,
|
||||
filterConfig,
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({ type: SET_FILTER_CONFIG_FAIL, filterConfig });
|
||||
}
|
||||
};
|
||||
|
||||
export const SET_EXTRA_FORM_DATA = 'SET_EXTRA_FORM_DATA';
|
||||
export interface SetExtraFormData {
|
||||
type: typeof SET_EXTRA_FORM_DATA;
|
||||
filterId: string;
|
||||
extraFormData: ExtraFormData;
|
||||
}
|
||||
|
||||
export function setFilterState(
|
||||
selectedValues: SelectedValues,
|
||||
filter: Filter,
|
||||
filters: FilterConfiguration,
|
||||
) {
|
||||
return {
|
||||
type: SET_FILTER_STATE,
|
||||
selectedValues,
|
||||
filter,
|
||||
filters,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Sets the selected option(s) for a given filter
|
||||
* @param filterId the id of the native filter
|
||||
* @param extraFormData the selection translated into extra form data
|
||||
*/
|
||||
export function setExtraFormData(
|
||||
filterId: string,
|
||||
extraFormData: ExtraFormData,
|
||||
): SetExtraFormData {
|
||||
return {
|
||||
type: SET_EXTRA_FORM_DATA,
|
||||
filterId,
|
||||
extraFormData,
|
||||
};
|
||||
}
|
||||
|
||||
export type AnyFilterAction =
|
||||
| SetFilterConfigBegin
|
||||
| SetFilterConfigComplete
|
||||
| SetFilterConfigFail
|
||||
| SetExtraFormData
|
||||
| SetFilterState;
|
||||
@@ -1,110 +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-env browser */
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Tabs from 'src/common/components/Tabs';
|
||||
import { StickyContainer, Sticky } from 'react-sticky';
|
||||
import { ParentSize } from '@vx/responsive';
|
||||
|
||||
import { t, styled } from '@superset-ui/core';
|
||||
|
||||
import NewColumn from './gridComponents/new/NewColumn';
|
||||
import NewDivider from './gridComponents/new/NewDivider';
|
||||
import NewHeader from './gridComponents/new/NewHeader';
|
||||
import NewRow from './gridComponents/new/NewRow';
|
||||
import NewTabs from './gridComponents/new/NewTabs';
|
||||
import NewMarkdown from './gridComponents/new/NewMarkdown';
|
||||
import SliceAdder from '../containers/SliceAdder';
|
||||
|
||||
const propTypes = {
|
||||
topOffset: PropTypes.number,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
topOffset: 0,
|
||||
};
|
||||
|
||||
const SUPERSET_HEADER_HEIGHT = 59;
|
||||
|
||||
const BuilderComponentPaneTabs = styled(Tabs)`
|
||||
line-height: inherit;
|
||||
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
`;
|
||||
|
||||
class BuilderComponentPane extends React.PureComponent {
|
||||
renderTabs(height) {
|
||||
const { isSticky } = this.props;
|
||||
return (
|
||||
<BuilderComponentPaneTabs
|
||||
id="tabs"
|
||||
className="tabs-components"
|
||||
data-test="dashboard-builder-component-pane-tabs-navigation"
|
||||
>
|
||||
<Tabs.TabPane key={1} tab={t('Components')}>
|
||||
<NewTabs />
|
||||
<NewRow />
|
||||
<NewColumn />
|
||||
<NewHeader />
|
||||
<NewMarkdown />
|
||||
<NewDivider />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key={2} tab={t('Charts')} className="tab-charts">
|
||||
<SliceAdder
|
||||
height={height + (isSticky ? SUPERSET_HEADER_HEIGHT : 0)}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</BuilderComponentPaneTabs>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { topOffset } = this.props;
|
||||
return (
|
||||
<div
|
||||
className="dashboard-builder-sidepane"
|
||||
style={{
|
||||
height: `calc(100vh - ${topOffset + SUPERSET_HEADER_HEIGHT}px)`,
|
||||
}}
|
||||
>
|
||||
<ParentSize>
|
||||
{({ height }) => (
|
||||
<StickyContainer>
|
||||
<Sticky topOffset={-topOffset} bottomOffset={Infinity}>
|
||||
{({ style, isSticky }) => (
|
||||
<div
|
||||
className="viewport"
|
||||
style={isSticky ? { ...style, top: topOffset } : null}
|
||||
>
|
||||
{this.renderTabs(height)}
|
||||
</div>
|
||||
)}
|
||||
</Sticky>
|
||||
</StickyContainer>
|
||||
)}
|
||||
</ParentSize>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BuilderComponentPane.propTypes = propTypes;
|
||||
BuilderComponentPane.defaultProps = defaultProps;
|
||||
|
||||
export default BuilderComponentPane;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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-env browser */
|
||||
import React from 'react';
|
||||
import Tabs from 'src/common/components/Tabs';
|
||||
import { StickyContainer, Sticky } from 'react-sticky';
|
||||
import { ParentSize } from '@vx/responsive';
|
||||
|
||||
import { t, styled } from '@superset-ui/core';
|
||||
|
||||
import NewColumn from './gridComponents/new/NewColumn';
|
||||
import NewDivider from './gridComponents/new/NewDivider';
|
||||
import NewHeader from './gridComponents/new/NewHeader';
|
||||
import NewRow from './gridComponents/new/NewRow';
|
||||
import NewTabs from './gridComponents/new/NewTabs';
|
||||
import NewMarkdown from './gridComponents/new/NewMarkdown';
|
||||
import SliceAdder from '../containers/SliceAdder';
|
||||
|
||||
export interface BCPProps {
|
||||
topOffset: number;
|
||||
}
|
||||
|
||||
const SUPERSET_HEADER_HEIGHT = 59;
|
||||
|
||||
const BuilderComponentPaneTabs = styled(Tabs)`
|
||||
line-height: inherit;
|
||||
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
`;
|
||||
|
||||
const BuilderComponentPane: React.FC<BCPProps> = ({ topOffset = 0 }) => {
|
||||
return (
|
||||
<div
|
||||
className="dashboard-builder-sidepane"
|
||||
style={{
|
||||
height: `calc(100vh - ${topOffset + SUPERSET_HEADER_HEIGHT}px)`,
|
||||
}}
|
||||
>
|
||||
<ParentSize>
|
||||
{({ height }) => (
|
||||
<StickyContainer>
|
||||
<Sticky topOffset={-topOffset} bottomOffset={Infinity}>
|
||||
{({ style, isSticky }: { style: any; isSticky: boolean }) => (
|
||||
<div
|
||||
className="viewport"
|
||||
style={isSticky ? { ...style, top: topOffset } : null}
|
||||
>
|
||||
<BuilderComponentPaneTabs
|
||||
id="tabs"
|
||||
className="tabs-components"
|
||||
data-test="dashboard-builder-component-pane-tabs-navigation"
|
||||
>
|
||||
<Tabs.TabPane key={1} tab={t('Components')}>
|
||||
<NewTabs />
|
||||
<NewRow />
|
||||
<NewColumn />
|
||||
<NewHeader />
|
||||
<NewMarkdown />
|
||||
<NewDivider />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane
|
||||
key={2}
|
||||
tab={t('Charts')}
|
||||
className="tab-charts"
|
||||
>
|
||||
<SliceAdder
|
||||
height={
|
||||
height + (isSticky ? SUPERSET_HEADER_HEIGHT : 0)
|
||||
}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</BuilderComponentPaneTabs>
|
||||
</div>
|
||||
)}
|
||||
</Sticky>
|
||||
</StickyContainer>
|
||||
)}
|
||||
</ParentSize>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuilderComponentPane;
|
||||
@@ -141,12 +141,15 @@ class Dashboard extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
componentDidUpdate(prevProps) {
|
||||
const { hasUnsavedChanges, editMode } = this.props.dashboardState;
|
||||
|
||||
const { appliedFilters } = this;
|
||||
const { activeFilters } = this.props;
|
||||
const { activeFilters, nativeFilters } = this.props;
|
||||
// do not apply filter when dashboard in edit mode
|
||||
if (!areObjectsEqual(prevProps.nativeFilters, nativeFilters)) {
|
||||
this.refreshCharts(this.getAllCharts().map(chart => chart.id));
|
||||
}
|
||||
if (!editMode && !areObjectsEqual(appliedFilters, activeFilters)) {
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Sticky, StickyContainer } from 'react-sticky';
|
||||
import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
|
||||
import { styled } from '@superset-ui/core';
|
||||
|
||||
import ErrorBoundary from 'src/components/ErrorBoundary';
|
||||
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
|
||||
import DashboardHeader from 'src/dashboard/containers/DashboardHeader';
|
||||
import DashboardGrid from 'src/dashboard/containers/DashboardGrid';
|
||||
@@ -41,11 +42,14 @@ import findTabIndexByComponentId from 'src/dashboard/util/findTabIndexByComponen
|
||||
|
||||
import getDirectPathToTabIndex from 'src/dashboard/util/getDirectPathToTabIndex';
|
||||
import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
import {
|
||||
DASHBOARD_GRID_ID,
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_ROOT_DEPTH,
|
||||
} from '../util/constants';
|
||||
import FilterBar from './nativeFilters/FilterBar';
|
||||
import { StickyVerticalBar } from './StickyVerticalBar';
|
||||
|
||||
const TABS_HEIGHT = 47;
|
||||
const HEADER_HEIGHT = 67;
|
||||
@@ -76,16 +80,21 @@ const StyledDashboardContent = styled.div`
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
height: auto;
|
||||
flex-grow: 1;
|
||||
|
||||
.grid-container .dashboard-component-tabs {
|
||||
box-shadow: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
& > div:first-child {
|
||||
.grid-container {
|
||||
/* without this, the grid will not get smaller upon toggling the builder panel on */
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
margin: ${({ theme }) => theme.gridUnit * 6}px
|
||||
${({ theme }) => theme.gridUnit * 9}px;
|
||||
}
|
||||
|
||||
.dashboard-component-chart-holder {
|
||||
@@ -137,10 +146,14 @@ class DashboardBuilder extends React.Component {
|
||||
);
|
||||
this.state = {
|
||||
tabIndex,
|
||||
dashboardFiltersOpen: true,
|
||||
};
|
||||
|
||||
this.handleChangeTab = this.handleChangeTab.bind(this);
|
||||
this.handleDeleteTopLevelTabs = this.handleDeleteTopLevelTabs.bind(this);
|
||||
this.toggleDashboardFiltersOpen = this.toggleDashboardFiltersOpen.bind(
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
getChildContext() {
|
||||
@@ -167,6 +180,24 @@ class DashboardBuilder extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
toggleDashboardFiltersOpen(visible) {
|
||||
if (visible === undefined) {
|
||||
this.setState(state => ({
|
||||
...state,
|
||||
dashboardFiltersOpen: !state.dashboardFiltersOpen,
|
||||
}));
|
||||
} else {
|
||||
this.setState(state => ({
|
||||
...state,
|
||||
dashboardFiltersOpen: visible,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
handleChangeTab({ pathToTabIndex }) {
|
||||
this.props.setDirectPathToChild(pathToTabIndex);
|
||||
}
|
||||
|
||||
handleDeleteTopLevelTabs() {
|
||||
this.props.deleteTopLevelTabs();
|
||||
|
||||
@@ -178,10 +209,6 @@ class DashboardBuilder extends React.Component {
|
||||
this.props.setDirectPathToChild(firstTab);
|
||||
}
|
||||
|
||||
handleChangeTab({ pathToTabIndex }) {
|
||||
this.props.setDirectPathToChild(pathToTabIndex);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
handleComponentDrop,
|
||||
@@ -199,6 +226,8 @@ class DashboardBuilder extends React.Component {
|
||||
|
||||
const childIds = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID];
|
||||
|
||||
const barTopOffset = HEADER_HEIGHT + (topLevelTabs ? TABS_HEIGHT : 0);
|
||||
|
||||
return (
|
||||
<StickyContainer
|
||||
className={cx('dashboard', editMode && 'dashboard--editing')}
|
||||
@@ -251,6 +280,19 @@ class DashboardBuilder extends React.Component {
|
||||
</Sticky>
|
||||
|
||||
<StyledDashboardContent className="dashboard-content">
|
||||
{isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) && (
|
||||
<StickyVerticalBar
|
||||
filtersOpen={this.state.dashboardFiltersOpen}
|
||||
topOffset={barTopOffset}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<FilterBar
|
||||
filtersOpen={this.state.dashboardFiltersOpen}
|
||||
toggleFiltersBar={this.toggleDashboardFiltersOpen}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</StickyVerticalBar>
|
||||
)}
|
||||
<div className="grid-container" data-test="grid-container">
|
||||
<ParentSize>
|
||||
{({ width }) => (
|
||||
@@ -293,7 +335,7 @@ class DashboardBuilder extends React.Component {
|
||||
</div>
|
||||
{editMode && (
|
||||
<BuilderComponentPane
|
||||
topOffset={HEADER_HEIGHT + (topLevelTabs ? TABS_HEIGHT : 0)}
|
||||
topOffset={barTopOffset}
|
||||
showBuilderPane={showBuilderPane}
|
||||
setColorSchemeAndUnsavedChanges={setColorSchemeAndUnsavedChanges}
|
||||
colorScheme={colorScheme}
|
||||
|
||||
@@ -132,12 +132,12 @@ export const selectIndicatorsForChart = (
|
||||
// for now we only need to know which columns are compatible/incompatible,
|
||||
// so grab the columns from the applied/rejected filters
|
||||
const appliedColumns: Set<string> = new Set(
|
||||
(chart?.queryResponse?.applied_filters || []).map(
|
||||
(chart?.queriesResponse?.[0]?.applied_filters || []).map(
|
||||
(filter: any) => filter.column,
|
||||
),
|
||||
);
|
||||
const rejectedColumns: Set<string> = new Set(
|
||||
(chart?.queryResponse?.rejected_filters || []).map(
|
||||
(chart?.queriesResponse?.[0]?.rejected_filters || []).map(
|
||||
(filter: any) => filter.column,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -102,6 +102,14 @@ const defaultProps = {
|
||||
|
||||
// Styled Components
|
||||
const StyledDashboardHeader = styled.div`
|
||||
background: ${({ theme }) => theme.colors.grayscale.light5};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 ${({ theme }) => theme.gridUnit * 6}px;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
||||
|
||||
button,
|
||||
.fave-unfave-icon {
|
||||
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
@@ -471,14 +479,17 @@ class Header extends React.PureComponent {
|
||||
)}
|
||||
|
||||
{!editMode && userCanEdit && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={this.toggleEditMode}
|
||||
>
|
||||
<Icon name="edit-alt" />
|
||||
</span>
|
||||
<>
|
||||
<span
|
||||
role="button"
|
||||
title="Edit Dashboard"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={this.toggleEditMode}
|
||||
>
|
||||
<Icon name="edit-alt" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{this.state.showingPropertiesModal && (
|
||||
|
||||
@@ -29,8 +29,8 @@ const propTypes = {
|
||||
innerRef: PropTypes.func,
|
||||
slice: PropTypes.object.isRequired,
|
||||
isExpanded: PropTypes.bool,
|
||||
isCached: PropTypes.bool,
|
||||
cachedDttm: PropTypes.string,
|
||||
isCached: PropTypes.arrayOf(PropTypes.bool),
|
||||
cachedDttm: PropTypes.arrayOf(PropTypes.string),
|
||||
updatedDttm: PropTypes.number,
|
||||
updateSliceName: PropTypes.func,
|
||||
toggleExpandSlice: PropTypes.func,
|
||||
@@ -64,8 +64,8 @@ const defaultProps = {
|
||||
annotationError: {},
|
||||
cachedDttm: null,
|
||||
updatedDttm: null,
|
||||
isCached: false,
|
||||
isExpanded: false,
|
||||
isCached: [],
|
||||
isExpanded: [],
|
||||
sliceName: '',
|
||||
supersetCanExplore: false,
|
||||
supersetCanCSV: false,
|
||||
|
||||
@@ -31,9 +31,9 @@ const propTypes = {
|
||||
componentId: PropTypes.string.isRequired,
|
||||
dashboardId: PropTypes.number.isRequired,
|
||||
addDangerToast: PropTypes.func.isRequired,
|
||||
isCached: PropTypes.bool,
|
||||
isCached: PropTypes.arrayOf(PropTypes.bool),
|
||||
cachedDttm: PropTypes.arrayOf(PropTypes.string),
|
||||
isExpanded: PropTypes.bool,
|
||||
cachedDttm: PropTypes.string,
|
||||
updatedDttm: PropTypes.number,
|
||||
supersetCanExplore: PropTypes.bool,
|
||||
supersetCanCSV: PropTypes.bool,
|
||||
@@ -49,9 +49,9 @@ const defaultProps = {
|
||||
toggleExpandSlice: () => ({}),
|
||||
exploreChart: () => ({}),
|
||||
exportCSV: () => ({}),
|
||||
cachedDttm: null,
|
||||
cachedDttm: [],
|
||||
updatedDttm: null,
|
||||
isCached: false,
|
||||
isCached: [],
|
||||
isExpanded: false,
|
||||
supersetCanExplore: false,
|
||||
supersetCanCSV: false,
|
||||
@@ -82,9 +82,14 @@ const VerticalDotsContainer = styled.div`
|
||||
`;
|
||||
|
||||
const RefreshTooltip = styled.div`
|
||||
height: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
height: auto;
|
||||
margin: ${({ theme }) => theme.gridUnit}px 0;
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
line-height: ${({ theme }) => theme.typography.sizes.m * 1.5}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
const SCREENSHOT_NODE_SELECTOR = '.dashboard-component-chart-holder';
|
||||
@@ -171,13 +176,26 @@ class SliceHeaderControls extends React.PureComponent {
|
||||
addDangerToast,
|
||||
isFullSize,
|
||||
} = this.props;
|
||||
const cachedWhen = moment.utc(cachedDttm).fromNow();
|
||||
const cachedWhen = cachedDttm.map(itemCachedDttm =>
|
||||
moment.utc(itemCachedDttm).fromNow(),
|
||||
);
|
||||
const updatedWhen = updatedDttm ? moment.utc(updatedDttm).fromNow() : '';
|
||||
const refreshTooltip = isCached
|
||||
? t('Cached %s', cachedWhen)
|
||||
: (updatedWhen && t('Fetched %s', updatedWhen)) || '';
|
||||
const getCachedTitle = itemCached => {
|
||||
return itemCached
|
||||
? t('Cached %s', cachedWhen)
|
||||
: updatedWhen && t('Fetched %s', updatedWhen);
|
||||
};
|
||||
const refreshTooltipData = isCached.map(getCachedTitle) || '';
|
||||
// If all queries have same cache time we can unit them to one
|
||||
let refreshTooltip = [...new Set(refreshTooltipData)];
|
||||
refreshTooltip = refreshTooltip.map((item, index) => (
|
||||
<div>
|
||||
{refreshTooltip.length > 1
|
||||
? `${t('Query')} ${index + 1}: ${item}`
|
||||
: item}
|
||||
</div>
|
||||
));
|
||||
const resizeLabel = isFullSize ? t('Minimize Chart') : t('Maximize Chart');
|
||||
|
||||
const menu = (
|
||||
<Menu
|
||||
onClick={this.handleMenuClick}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { StickyContainer, Sticky } from 'react-sticky';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import cx from 'classnames';
|
||||
|
||||
export const SUPERSET_HEADER_HEIGHT = 59;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
width: 16px;
|
||||
flex: 0 0 16px;
|
||||
/* these animations (which can be enabled with the "animated" class) look glitchy due to chart resizing */
|
||||
/* keeping these for posterity, in case we can improve that resizing performance */
|
||||
/* &.animated {
|
||||
transition: width 0;
|
||||
transition-delay: ${({ theme }) =>
|
||||
theme.transitionTiming * 2}s;
|
||||
} */
|
||||
&.open {
|
||||
width: 250px;
|
||||
flex: 0 0 250px;
|
||||
/* &.animated {
|
||||
transition-delay: 0s;
|
||||
} */
|
||||
}
|
||||
`;
|
||||
|
||||
const Contents = styled.div`
|
||||
display: grid;
|
||||
position: absolute;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export interface SVBProps {
|
||||
topOffset: number;
|
||||
width: number;
|
||||
filtersOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertical sidebar that uses sticky position to stay
|
||||
* fixed on the page after the sitenav is scrolled out of the viewport.
|
||||
*
|
||||
* TODO use css position: sticky when sufficiently supported
|
||||
* (should have better performance)
|
||||
*/
|
||||
export const StickyVerticalBar: React.FC<SVBProps> = ({
|
||||
topOffset,
|
||||
children,
|
||||
filtersOpen,
|
||||
}) => {
|
||||
return (
|
||||
<Wrapper className={cx({ open: filtersOpen })}>
|
||||
<StickyContainer>
|
||||
<Sticky topOffset={-topOffset} bottomOffset={Infinity}>
|
||||
{({ style, isSticky }: { style: any; isSticky: boolean }) => (
|
||||
<Contents style={isSticky ? { ...style, top: topOffset } : null}>
|
||||
{children}
|
||||
</Contents>
|
||||
)}
|
||||
</Sticky>
|
||||
</StickyContainer>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from '../../../logger/LogUtils';
|
||||
import { isFilterBox } from '../../util/activeDashboardFilters';
|
||||
import getFilterValuesByFilterId from '../../util/getFilterValuesByFilterId';
|
||||
import { areObjectsEqual } from '../../../reduxUtils';
|
||||
|
||||
const propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
@@ -133,13 +134,6 @@ export default class Chart extends React.Component {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < SHOULD_UPDATE_ON_PROP_CHANGES.length; i += 1) {
|
||||
const prop = SHOULD_UPDATE_ON_PROP_CHANGES[i];
|
||||
if (nextProps[prop] !== this.props[prop]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
nextProps.width !== this.props.width ||
|
||||
nextProps.height !== this.props.height
|
||||
@@ -147,6 +141,15 @@ export default class Chart extends React.Component {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
this.resizeTimeout = setTimeout(this.resize, RESIZE_TIMEOUT);
|
||||
}
|
||||
|
||||
for (let i = 0; i < SHOULD_UPDATE_ON_PROP_CHANGES.length; i += 1) {
|
||||
const prop = SHOULD_UPDATE_ON_PROP_CHANGES[i];
|
||||
// use deep objects equality comparison to prevent
|
||||
// unneccessary updates when objects references change
|
||||
if (!areObjectsEqual(nextProps[prop], this.props[prop])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `cacheBusterProp` is jected by react-hot-loader
|
||||
@@ -266,10 +269,13 @@ export default class Chart extends React.Component {
|
||||
return <MissingChart height={this.getChartHeight()} />;
|
||||
}
|
||||
|
||||
const { queryResponse, chartUpdateEndTime, chartStatus } = chart;
|
||||
const { queriesResponse, chartUpdateEndTime, chartStatus } = chart;
|
||||
const isLoading = chartStatus === 'loading';
|
||||
const isCached = queryResponse && queryResponse.is_cached;
|
||||
const cachedDttm = queryResponse && queryResponse.cached_dttm;
|
||||
// eslint-disable-next-line camelcase
|
||||
const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || [];
|
||||
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({
|
||||
@@ -277,7 +283,6 @@ export default class Chart extends React.Component {
|
||||
filterId: id,
|
||||
})
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div className="chart-slice">
|
||||
<SliceHeader
|
||||
@@ -352,7 +357,7 @@ export default class Chart extends React.Component {
|
||||
dashboardId={dashboardId}
|
||||
initialValues={initialValues}
|
||||
formData={formData}
|
||||
queryResponse={chart.queryResponse}
|
||||
queriesResponse={chart.queriesResponse}
|
||||
timeout={timeout}
|
||||
triggerQuery={chart.triggerQuery}
|
||||
vizType={slice.viz_type}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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, { useCallback } from 'react';
|
||||
import { ExtraFormData, styled, t } from '@superset-ui/core';
|
||||
import Popover from 'src/common/components/Popover';
|
||||
import Icon from 'src/components/Icon';
|
||||
import { Pill } from 'src/dashboard/components/FiltersBadge/Styles';
|
||||
import { CascadeFilterControl, FilterControl } from './FilterBar';
|
||||
import { Filter, CascadeFilter } from './types';
|
||||
|
||||
interface CascadePopoverProps {
|
||||
filter: CascadeFilter;
|
||||
visible: boolean;
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
onExtraFormDataChange: (filter: Filter, extraFormData: ExtraFormData) => void;
|
||||
}
|
||||
|
||||
const StyledTitleBox = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.colors.grayscale.light4};
|
||||
margin: ${({ theme }) => theme.gridUnit * -1}px
|
||||
${({ theme }) => theme.gridUnit * -4}px; // to override default antd padding
|
||||
padding: ${({ theme }) => theme.gridUnit * 2}px
|
||||
${({ theme }) => theme.gridUnit * 4}px;
|
||||
|
||||
& > *:last-child {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.h4`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.colors.grayscale.dark1};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-right: ${({ theme }) => theme.gridUnit}px;
|
||||
color: ${({ theme }) => theme.colors.grayscale.dark1};
|
||||
width: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
`;
|
||||
|
||||
const StyledPill = styled(Pill)`
|
||||
padding: ${({ theme }) => theme.gridUnit}px
|
||||
${({ theme }) => theme.gridUnit * 2}px;
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||
background: ${({ theme }) => theme.colors.grayscale.light1};
|
||||
`;
|
||||
|
||||
const CascadePopover: React.FC<CascadePopoverProps> = ({
|
||||
filter,
|
||||
visible,
|
||||
onVisibleChange,
|
||||
onExtraFormDataChange,
|
||||
}) => {
|
||||
const getActiveChildren = useCallback((filter: CascadeFilter):
|
||||
| CascadeFilter[]
|
||||
| null => {
|
||||
const children = filter.cascadeChildren || [];
|
||||
const currentValue = filter.currentValue || [];
|
||||
|
||||
const activeChildren = children.flatMap(
|
||||
childFilter => getActiveChildren(childFilter) || [],
|
||||
);
|
||||
|
||||
if (activeChildren.length > 0) {
|
||||
return activeChildren;
|
||||
}
|
||||
|
||||
if (currentValue.length > 0) {
|
||||
return [filter];
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
if (!filter.cascadeChildren?.length) {
|
||||
return (
|
||||
<FilterControl
|
||||
filter={filter}
|
||||
onExtraFormDataChange={onExtraFormDataChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const countFilters = (filter: CascadeFilter): number => {
|
||||
let count = 1;
|
||||
filter.cascadeChildren.forEach(child => {
|
||||
count += countFilters(child);
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
const totalChildren = countFilters(filter);
|
||||
|
||||
const title = (
|
||||
<StyledTitleBox>
|
||||
<StyledTitle>
|
||||
<StyledIcon name="edit" />
|
||||
{t('Select Parent Filters')} ({totalChildren})
|
||||
</StyledTitle>
|
||||
<StyledIcon name="close" onClick={() => onVisibleChange(false)} />
|
||||
</StyledTitleBox>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<CascadeFilterControl
|
||||
data-test="cascade-filters-control"
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
onExtraFormDataChange={onExtraFormDataChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const activeFilters = getActiveChildren(filter) || [filter];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
title={title}
|
||||
trigger="click"
|
||||
visible={visible}
|
||||
onVisibleChange={onVisibleChange}
|
||||
placement="rightTop"
|
||||
id={filter.id}
|
||||
overlayStyle={{ minWidth: '400px', maxWidth: '600px' }}
|
||||
>
|
||||
<div>
|
||||
{activeFilters.map(activeFilter => (
|
||||
<FilterControl
|
||||
key={activeFilter.id}
|
||||
filter={activeFilter}
|
||||
onExtraFormDataChange={onExtraFormDataChange}
|
||||
icon={
|
||||
<>
|
||||
{filter.cascadeChildren.length !== 0 && (
|
||||
<StyledPill onClick={() => onVisibleChange(true)}>
|
||||
<Icon name="filter" /> {totalChildren}
|
||||
</StyledPill>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default CascadePopover;
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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, { useCallback } from 'react';
|
||||
import { FormInstance } from 'antd/lib/form';
|
||||
import { SupersetClient, t } from '@superset-ui/core';
|
||||
import { useChangeEffect } from 'src/common/hooks/useChangeEffect';
|
||||
import { AsyncSelect } from 'src/components/Select';
|
||||
import { useToasts } from 'src/messageToasts/enhancers/withToasts';
|
||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import { cacheWrapper } from 'src/utils/cacheWrapper';
|
||||
import { NativeFiltersForm } from './types';
|
||||
|
||||
type ColumnSelectValue = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
interface ColumnSelectProps {
|
||||
form: FormInstance<NativeFiltersForm>;
|
||||
filterId: string;
|
||||
datasetId?: number | null | undefined;
|
||||
value?: ColumnSelectValue | null;
|
||||
onChange?: (value: ColumnSelectValue | null) => void;
|
||||
}
|
||||
|
||||
const localCache = new Map<string, any>();
|
||||
|
||||
const cachedSupersetGet = cacheWrapper(
|
||||
SupersetClient.get,
|
||||
localCache,
|
||||
({ endpoint }) => endpoint || '',
|
||||
);
|
||||
|
||||
/** Special purpose AsyncSelect that selects a column from a dataset */
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function ColumnSelect({
|
||||
form,
|
||||
filterId,
|
||||
datasetId,
|
||||
value,
|
||||
onChange,
|
||||
}: ColumnSelectProps) {
|
||||
const { addDangerToast } = useToasts();
|
||||
const resetColumnField = useCallback(() => {
|
||||
form.setFields([
|
||||
{ name: ['filters', filterId, 'column'], touched: false, value: null },
|
||||
]);
|
||||
}, [form, filterId]);
|
||||
useChangeEffect(datasetId, previous => {
|
||||
if (previous != null) {
|
||||
resetColumnField();
|
||||
}
|
||||
});
|
||||
|
||||
function loadOptions() {
|
||||
if (datasetId == null) return [];
|
||||
return cachedSupersetGet({
|
||||
endpoint: `/api/v1/dataset/${datasetId}`,
|
||||
}).then(
|
||||
({ json: { result } }) => {
|
||||
return result.columns
|
||||
.map((col: any) => col.column_name)
|
||||
.sort((a: string, b: string) => a.localeCompare(b));
|
||||
},
|
||||
async badResponse => {
|
||||
const { error, message } = await getClientErrorObject(badResponse);
|
||||
let errorText = message || error || t('An error has occurred');
|
||||
if (message === 'Forbidden') {
|
||||
errorText = t('You do not have permission to edit this dashboard');
|
||||
}
|
||||
addDangerToast(errorText);
|
||||
return [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
// "key" prop makes react render a new instance of the select whenever the dataset changes
|
||||
key={datasetId == null ? '*no dataset*' : datasetId}
|
||||
isDisabled={datasetId == null}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isMulti={false}
|
||||
loadOptions={loadOptions}
|
||||
defaultOptions // load options on render
|
||||
cacheOptions
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* 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 {
|
||||
QueryFormData,
|
||||
styled,
|
||||
SuperChart,
|
||||
t,
|
||||
ExtraFormData,
|
||||
} from '@superset-ui/core';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import cx from 'classnames';
|
||||
import { Form } from 'src/common/components';
|
||||
import Button from 'src/components/Button';
|
||||
import Icon from 'src/components/Icon';
|
||||
import { getChartDataRequest } from 'src/chart/chartAction';
|
||||
import { areObjectsEqual } from 'src/reduxUtils';
|
||||
import FilterConfigurationLink from './FilterConfigurationLink';
|
||||
// import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal';
|
||||
|
||||
import {
|
||||
useCascadingFilters,
|
||||
useFilterConfiguration,
|
||||
useSetExtraFormData,
|
||||
} from './state';
|
||||
import { Filter, CascadeFilter } from './types';
|
||||
import { buildCascadeFiltersTree, mapParentFiltersToChildren } from './utils';
|
||||
import CascadePopover from './CascadePopover';
|
||||
|
||||
const barWidth = `250px`;
|
||||
|
||||
const BarWrapper = styled.div`
|
||||
width: ${({ theme }) => theme.gridUnit * 6}px;
|
||||
&.open {
|
||||
width: ${barWidth}; // arbitrary...
|
||||
}
|
||||
`;
|
||||
|
||||
const Bar = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
width: ${barWidth}; // arbitrary...
|
||||
background: ${({ theme }) => theme.colors.grayscale.light5};
|
||||
border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: none;
|
||||
/* &.animated {
|
||||
display: flex;
|
||||
transform: translateX(-100%);
|
||||
transition: transform ${({
|
||||
theme,
|
||||
}) => theme.transitionTiming}s;
|
||||
transition-delay: 0s;
|
||||
} */
|
||||
&.open {
|
||||
display: flex;
|
||||
/* &.animated {
|
||||
transform: translateX(0);
|
||||
transition-delay: ${({
|
||||
theme,
|
||||
}) => theme.transitionTiming * 2}s;
|
||||
} */
|
||||
}
|
||||
`;
|
||||
|
||||
const CollapsedBar = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: ${({ theme }) => theme.gridUnit * 6}px;
|
||||
padding-top: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
display: none;
|
||||
text-align: center;
|
||||
/* &.animated {
|
||||
display: block;
|
||||
transform: translateX(-100%);
|
||||
transition: transform ${({
|
||||
theme,
|
||||
}) => theme.transitionTiming}s;
|
||||
transition-delay: 0s;
|
||||
} */
|
||||
&.open {
|
||||
display: block;
|
||||
/* &.animated {
|
||||
transform: translateX(0);
|
||||
transition-delay: ${({
|
||||
theme,
|
||||
}) => theme.transitionTiming * 3}s;
|
||||
} */
|
||||
}
|
||||
svg {
|
||||
width: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
height: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
const TitleArea = styled.h4`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
& > span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
& :not(:first-child) {
|
||||
margin-left: ${({ theme }) => theme.gridUnit}px;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
padding: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
padding-top: 0;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
||||
.btn {
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterControls = styled.div`
|
||||
padding: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
`;
|
||||
|
||||
const StyledCascadeChildrenList = styled.ul`
|
||||
list-style-type: none;
|
||||
& > * {
|
||||
list-style-type: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledFilterControlTitle = styled.h4`
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||
color: ${({ theme }) => theme.colors.grayscale.dark1};
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const StyledFilterControlTitleBox = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit}px;
|
||||
`;
|
||||
|
||||
const StyledFilterControlContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledFilterControlBox = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledCaretIcon = styled(Icon)`
|
||||
margin-top: ${({ theme }) => -theme.gridUnit}px;
|
||||
`;
|
||||
|
||||
interface FilterProps {
|
||||
filter: Filter;
|
||||
icon?: React.ReactElement;
|
||||
onExtraFormDataChange: (filter: Filter, extraFormData: ExtraFormData) => void;
|
||||
}
|
||||
|
||||
interface FiltersBarProps {
|
||||
filtersOpen: boolean;
|
||||
toggleFiltersBar: any;
|
||||
}
|
||||
|
||||
const FilterValue: React.FC<FilterProps> = ({
|
||||
filter,
|
||||
onExtraFormDataChange,
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
allowsMultipleValues,
|
||||
inverseSelection,
|
||||
targets,
|
||||
currentValue,
|
||||
defaultValue,
|
||||
} = filter;
|
||||
const cascadingFilters = useCascadingFilters(id);
|
||||
const [state, setState] = useState({ data: undefined });
|
||||
const [formData, setFormData] = useState<Partial<QueryFormData>>({});
|
||||
const [target] = targets;
|
||||
const { datasetId = 18, column } = target;
|
||||
const { name: groupby } = column;
|
||||
|
||||
const getFormData = (): Partial<QueryFormData> => ({
|
||||
adhoc_filters: [],
|
||||
datasource: `${datasetId}__table`,
|
||||
extra_filters: [],
|
||||
extra_form_data: cascadingFilters,
|
||||
granularity_sqla: 'ds',
|
||||
groupby: [groupby],
|
||||
inverseSelection,
|
||||
metrics: ['count'],
|
||||
multiSelect: allowsMultipleValues,
|
||||
row_limit: 10000,
|
||||
showSearch: true,
|
||||
time_range: 'No filter',
|
||||
time_range_endpoints: ['inclusive', 'exclusive'],
|
||||
url_params: {},
|
||||
viz_type: 'filter_select',
|
||||
defaultValues: currentValue || defaultValue || [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const newFormData = getFormData();
|
||||
if (!areObjectsEqual(formData || {}, newFormData)) {
|
||||
setFormData(newFormData);
|
||||
getChartDataRequest({
|
||||
formData: newFormData,
|
||||
force: false,
|
||||
requestParams: { dashboardId: 0 },
|
||||
}).then(response => {
|
||||
setState({ data: response.result[0].data });
|
||||
});
|
||||
}
|
||||
}, [cascadingFilters]);
|
||||
|
||||
const setExtraFormData = (extraFormData: ExtraFormData) =>
|
||||
onExtraFormDataChange(filter, extraFormData);
|
||||
|
||||
return (
|
||||
<Form
|
||||
onFinish={values => {
|
||||
setExtraFormData(values.value);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="value">
|
||||
<SuperChart
|
||||
height={20}
|
||||
width={220}
|
||||
formData={getFormData()}
|
||||
queriesData={[state]}
|
||||
chartType="filter_select"
|
||||
hooks={{ setExtraFormData }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterControl: React.FC<FilterProps> = ({
|
||||
filter,
|
||||
icon,
|
||||
onExtraFormDataChange,
|
||||
}) => {
|
||||
const { name = '<undefined>' } = filter;
|
||||
return (
|
||||
<StyledFilterControlContainer>
|
||||
<StyledFilterControlTitleBox>
|
||||
<StyledFilterControlTitle>{name}</StyledFilterControlTitle>
|
||||
<div>{icon}</div>
|
||||
</StyledFilterControlTitleBox>
|
||||
<FilterValue
|
||||
filter={filter}
|
||||
onExtraFormDataChange={onExtraFormDataChange}
|
||||
/>
|
||||
</StyledFilterControlContainer>
|
||||
);
|
||||
};
|
||||
|
||||
interface CascadeFilterControlProps {
|
||||
filter: CascadeFilter;
|
||||
onExtraFormDataChange: (filter: Filter, extraFormData: ExtraFormData) => void;
|
||||
}
|
||||
|
||||
export const CascadeFilterControl: React.FC<CascadeFilterControlProps> = ({
|
||||
filter,
|
||||
onExtraFormDataChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<StyledFilterControlBox>
|
||||
<StyledCaretIcon name="caret-down" />
|
||||
<FilterControl
|
||||
filter={filter}
|
||||
onExtraFormDataChange={onExtraFormDataChange}
|
||||
/>
|
||||
</StyledFilterControlBox>
|
||||
|
||||
<StyledCascadeChildrenList>
|
||||
{filter.cascadeChildren?.map(childFilter => (
|
||||
<li>
|
||||
<CascadeFilterControl
|
||||
filter={childFilter}
|
||||
onExtraFormDataChange={onExtraFormDataChange}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</StyledCascadeChildrenList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterBar: React.FC<FiltersBarProps> = ({
|
||||
filtersOpen,
|
||||
toggleFiltersBar,
|
||||
}) => {
|
||||
const [filterData, setFilterData] = useState<{ [id: string]: ExtraFormData }>(
|
||||
{},
|
||||
);
|
||||
const setExtraFormData = useSetExtraFormData();
|
||||
const filterConfigs = useFilterConfiguration();
|
||||
const canEdit = useSelector<any, boolean>(
|
||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||
);
|
||||
const [visiblePopoverId, setVisiblePopoverId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterConfigs.length === 0 && filtersOpen) {
|
||||
toggleFiltersBar(false);
|
||||
}
|
||||
}, [filterConfigs]);
|
||||
|
||||
const getFilterValue = useCallback(
|
||||
(filter: Filter): (string | number | boolean)[] | null => {
|
||||
const filters = filterData[filter.id]?.append_form_data?.filters;
|
||||
if (filters?.length) {
|
||||
const filter = filters[0];
|
||||
if ('val' in filter) {
|
||||
// need to nest these if statements to get a reference to val to appease TS
|
||||
const { val } = filter;
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
return [val];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[filterData],
|
||||
);
|
||||
|
||||
const cascadeChildren = useMemo(
|
||||
() => mapParentFiltersToChildren(filterConfigs),
|
||||
[filterConfigs],
|
||||
);
|
||||
|
||||
const cascadeFilters = useMemo(() => {
|
||||
const filtersWithValue = filterConfigs.map(filter => ({
|
||||
...filter,
|
||||
currentValue: getFilterValue(filter),
|
||||
}));
|
||||
return buildCascadeFiltersTree(filtersWithValue);
|
||||
}, [filterConfigs, getFilterValue]);
|
||||
|
||||
const handleExtraFormDataChange = (
|
||||
filter: Filter,
|
||||
extraFormData: ExtraFormData,
|
||||
) => {
|
||||
setFilterData(prevFilterData => ({
|
||||
...prevFilterData,
|
||||
[filter.id]: extraFormData,
|
||||
}));
|
||||
|
||||
const children = cascadeChildren[filter.id] || [];
|
||||
// force instant updating for parent filters
|
||||
if (filter.isInstant || children.length > 0) {
|
||||
setExtraFormData(filter.id, extraFormData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const filterIds = Object.keys(filterData);
|
||||
filterIds.forEach(filterId => {
|
||||
if (filterData[filterId]) {
|
||||
setExtraFormData(filterId, filterData[filterId]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BarWrapper data-test="filter-bar" className={cx({ open: filtersOpen })}>
|
||||
<CollapsedBar
|
||||
className={cx({ open: !filtersOpen })}
|
||||
onClick={() => toggleFiltersBar(true)}
|
||||
>
|
||||
<Icon name="collapse" />
|
||||
<Icon name="filter" />
|
||||
</CollapsedBar>
|
||||
<Bar className={cx({ open: filtersOpen })}>
|
||||
<TitleArea>
|
||||
<span>
|
||||
{t('Filters')} ({filterConfigs.length})
|
||||
</span>
|
||||
{canEdit && (
|
||||
<FilterConfigurationLink
|
||||
createNewOnOpen={filterConfigs.length === 0}
|
||||
>
|
||||
<Icon name="edit" data-test="create-filter" />
|
||||
</FilterConfigurationLink>
|
||||
)}
|
||||
<Icon name="expand" onClick={() => toggleFiltersBar(false)} />
|
||||
</TitleArea>
|
||||
<ActionButtons>
|
||||
<Button buttonStyle="secondary" buttonSize="sm">
|
||||
{t('Reset All')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
type="submit"
|
||||
buttonSize="sm"
|
||||
onClick={handleApply}
|
||||
>
|
||||
{t('Apply')}
|
||||
</Button>
|
||||
</ActionButtons>
|
||||
<FilterControls>
|
||||
{cascadeFilters.map(filter => (
|
||||
<CascadePopover
|
||||
data-test="cascade-filters-control"
|
||||
key={filter.id}
|
||||
visible={visiblePopoverId === filter.id}
|
||||
onVisibleChange={visible =>
|
||||
setVisiblePopoverId(visible ? filter.id : null)
|
||||
}
|
||||
filter={filter}
|
||||
onExtraFormDataChange={handleExtraFormDataChange}
|
||||
/>
|
||||
))}
|
||||
</FilterControls>
|
||||
</Bar>
|
||||
</BarWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterBar;
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 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 { styled, t } from '@superset-ui/core';
|
||||
import { FormInstance } from 'antd/lib/form';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Typography,
|
||||
} from 'src/common/components';
|
||||
import { Select } from 'src/components/Select/SupersetStyledSelect';
|
||||
import SupersetResourceSelect, {
|
||||
Value,
|
||||
} from 'src/components/SupersetResourceSelect';
|
||||
import { addDangerToast } from 'src/messageToasts/actions';
|
||||
import { ClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import { ColumnSelect } from './ColumnSelect';
|
||||
import ScopingTree from './ScopingTree';
|
||||
import { Filter, NativeFiltersForm, Scoping } from './types';
|
||||
|
||||
type DatasetSelectValue = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const datasetToSelectOption = (item: any): DatasetSelectValue => ({
|
||||
value: item.id,
|
||||
label: item.table_name,
|
||||
});
|
||||
|
||||
const ScopingTreeNote = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
`;
|
||||
|
||||
const RemovedContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 400px; // arbitrary
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledFormItem = styled(Form.Item)`
|
||||
width: 49%;
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
`;
|
||||
|
||||
const StyledCheckboxFormItem = styled(Form.Item)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.span`
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
export interface FilterConfigFormProps {
|
||||
filterId: string;
|
||||
filterToEdit?: Filter;
|
||||
removed?: boolean;
|
||||
restore: (filterId: string) => void;
|
||||
form: FormInstance<NativeFiltersForm>;
|
||||
parentFilters: { id: string; title: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration form for a specific filter.
|
||||
* Assigns field values to `filters[filterId]` in the form.
|
||||
*/
|
||||
export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({
|
||||
filterId,
|
||||
filterToEdit,
|
||||
removed,
|
||||
restore,
|
||||
form,
|
||||
parentFilters,
|
||||
}) => {
|
||||
const [advancedScopingOpen, setAdvancedScopingOpen] = useState<Scoping>(
|
||||
Scoping.all,
|
||||
);
|
||||
const [dataset, setDataset] = useState<Value<number> | undefined>();
|
||||
|
||||
const onDatasetSelectError = useCallback(
|
||||
({ error, message }: ClientErrorObject) => {
|
||||
let errorText = message || error || t('An error has occurred');
|
||||
if (message === 'Forbidden') {
|
||||
errorText = t('You do not have permission to edit this dashboard');
|
||||
}
|
||||
addDangerToast(errorText);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setFilterScope = useCallback(
|
||||
value => {
|
||||
form.setFields([{ name: ['filters', filterId, 'scope'], value }]);
|
||||
},
|
||||
[form, filterId],
|
||||
);
|
||||
|
||||
if (removed) {
|
||||
return (
|
||||
<RemovedContent>
|
||||
<p>{t('You have removed this filter.')}</p>
|
||||
<div>
|
||||
<Button type="primary" onClick={() => restore(filterId)}>
|
||||
{t('Restore Filter')}
|
||||
</Button>
|
||||
</div>
|
||||
</RemovedContent>
|
||||
);
|
||||
}
|
||||
|
||||
const parentFilterOptions = parentFilters.map(filter => ({
|
||||
value: filter.id,
|
||||
label: filter.title,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Title level={5}>{t('Settings')}</Typography.Title>
|
||||
<StyledContainer>
|
||||
<StyledFormItem
|
||||
name={['filters', filterId, 'name']}
|
||||
label={<StyledLabel>{t('Filter Name')}</StyledLabel>}
|
||||
initialValue={filterToEdit?.name}
|
||||
rules={[{ required: !removed, message: t('Name is required') }]}
|
||||
data-test="name-input"
|
||||
>
|
||||
<Input />
|
||||
</StyledFormItem>
|
||||
|
||||
<StyledFormItem
|
||||
name={['filters', filterId, 'dataset']}
|
||||
label={<StyledLabel>{t('Datasource')}</StyledLabel>}
|
||||
rules={[{ required: !removed, message: t('Datasource is required') }]}
|
||||
data-test="datasource-input"
|
||||
>
|
||||
<SupersetResourceSelect
|
||||
initialId={filterToEdit?.targets[0].datasetId}
|
||||
resource="dataset"
|
||||
searchColumn="table_name"
|
||||
transformItem={datasetToSelectOption}
|
||||
isMulti={false}
|
||||
onChange={setDataset}
|
||||
onError={onDatasetSelectError}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
</StyledContainer>
|
||||
<StyledFormItem
|
||||
// don't show the column select unless we have a dataset
|
||||
// style={{ display: datasetId == null ? undefined : 'none' }}
|
||||
name={['filters', filterId, 'column']}
|
||||
initialValue={filterToEdit?.targets[0]?.column?.name}
|
||||
label={<StyledLabel>{t('Field')}</StyledLabel>}
|
||||
rules={[{ required: !removed, message: t('Field is required') }]}
|
||||
data-test="field-input"
|
||||
>
|
||||
<ColumnSelect
|
||||
form={form}
|
||||
filterId={filterId}
|
||||
datasetId={dataset?.value}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
|
||||
<StyledFormItem
|
||||
name={['filters', filterId, 'defaultValue']}
|
||||
label={<StyledLabel>{t('Default Value')}</StyledLabel>}
|
||||
initialValue={filterToEdit?.defaultValue}
|
||||
>
|
||||
<Input />
|
||||
</StyledFormItem>
|
||||
<StyledFormItem
|
||||
name={['filters', filterId, 'parentFilter']}
|
||||
label={<StyledLabel>{t('Parent Filter')}</StyledLabel>}
|
||||
initialValue={parentFilterOptions.find(
|
||||
({ value }) => value === filterToEdit?.cascadeParentIds[0],
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
placeholder={t('None')}
|
||||
options={parentFilterOptions}
|
||||
isClearable
|
||||
/>
|
||||
</StyledFormItem>
|
||||
|
||||
<StyledCheckboxFormItem
|
||||
name={['filters', filterId, 'isInstant']}
|
||||
initialValue={filterToEdit?.isInstant}
|
||||
valuePropName="checked"
|
||||
colon={false}
|
||||
>
|
||||
<Checkbox>{t('Apply changes instantly')}</Checkbox>
|
||||
</StyledCheckboxFormItem>
|
||||
<StyledCheckboxFormItem
|
||||
name={['filters', filterId, 'allowsMultipleValues']}
|
||||
initialValue={filterToEdit?.allowsMultipleValues}
|
||||
valuePropName="checked"
|
||||
colon={false}
|
||||
>
|
||||
<Checkbox>{t('Allow multiple selections')}</Checkbox>
|
||||
</StyledCheckboxFormItem>
|
||||
<StyledCheckboxFormItem
|
||||
name={['filters', filterId, 'inverseSelection']}
|
||||
initialValue={filterToEdit?.inverseSelection}
|
||||
valuePropName="checked"
|
||||
colon={false}
|
||||
>
|
||||
<Checkbox>{t('Inverse selection')}</Checkbox>
|
||||
</StyledCheckboxFormItem>
|
||||
<StyledCheckboxFormItem
|
||||
name={['filters', filterId, 'isRequired']}
|
||||
initialValue={filterToEdit?.isRequired}
|
||||
valuePropName="checked"
|
||||
colon={false}
|
||||
>
|
||||
<Checkbox>{t('Required')}</Checkbox>
|
||||
</StyledCheckboxFormItem>
|
||||
<Typography.Title level={5}>{t('Scoping')}</Typography.Title>
|
||||
<StyledCheckboxFormItem
|
||||
name={['filters', filterId, 'scoping']}
|
||||
initialValue={advancedScopingOpen}
|
||||
>
|
||||
<Radio.Group
|
||||
onChange={({ target: { value } }) => {
|
||||
setAdvancedScopingOpen(value as Scoping);
|
||||
}}
|
||||
>
|
||||
<Radio value={Scoping.all}>{t('Apply to all panels')}</Radio>
|
||||
<Radio value={Scoping.specific}>
|
||||
{t('Apply to specific panels')}
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</StyledCheckboxFormItem>
|
||||
<>
|
||||
<ScopingTreeNote>
|
||||
<Typography.Text type="secondary">
|
||||
{advancedScopingOpen === Scoping.specific
|
||||
? t('Only selected panels will be affected by this filter')
|
||||
: t(
|
||||
'All panels with this column will be affected by this filter',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</ScopingTreeNote>
|
||||
{advancedScopingOpen === Scoping.specific && (
|
||||
<ScopingTree setFilterScope={setFilterScope} />
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterConfigForm;
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* 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, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { findLastIndex, uniq } from 'lodash';
|
||||
import shortid from 'shortid';
|
||||
import { DeleteFilled, PlusOutlined } from '@ant-design/icons';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import { Form } from 'src/common/components';
|
||||
import { StyledModal } from 'src/common/components/Modal';
|
||||
import Button from 'src/components/Button';
|
||||
import { LineEditableTabs } from 'src/common/components/Tabs';
|
||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||
import { usePrevious } from 'src/common/hooks/usePrevious';
|
||||
import ErrorBoundary from 'src/components/ErrorBoundary';
|
||||
import { useFilterConfigMap, useFilterConfiguration } from './state';
|
||||
import FilterConfigForm from './FilterConfigForm';
|
||||
import { FilterConfiguration, NativeFiltersForm } from './types';
|
||||
|
||||
// how long to show the "undo" button when removing a filter
|
||||
const REMOVAL_DELAY_SECS = 5;
|
||||
|
||||
const StyledModalBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
.filters-list {
|
||||
width: ${({ theme }) => theme.gridUnit * 50}px;
|
||||
overflow: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledForm = styled(Form)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const FilterTabs = styled(LineEditableTabs)`
|
||||
// extra selector specificity:
|
||||
&.ant-tabs-card > .ant-tabs-nav .ant-tabs-tab {
|
||||
min-width: 200px;
|
||||
margin-left: 0;
|
||||
padding: 0 ${({ theme }) => theme.gridUnit * 2}px
|
||||
${({ theme }) => theme.gridUnit}px;
|
||||
|
||||
&:hover,
|
||||
&-active {
|
||||
color: ${({ theme }) => theme.colors.grayscale.dark1};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
background-color: ${({ theme }) => theme.colors.grayscale.light2};
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
text-align: left;
|
||||
justify-content: space-between;
|
||||
text-transform: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterTabTitle = styled.span`
|
||||
transition: color ${({ theme }) => theme.transitionTiming}s;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: ${({ theme }) => theme.gridUnit}px
|
||||
${({ theme }) => theme.gridUnit * 2}px 0 0;
|
||||
|
||||
@keyframes tabTitleRemovalAnimation {
|
||||
0%,
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
95%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.removed {
|
||||
color: ${({ theme }) => theme.colors.warning.dark1};
|
||||
transform-origin: top;
|
||||
animation-name: tabTitleRemovalAnimation;
|
||||
animation-duration: ${REMOVAL_DELAY_SECS}s;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledAddFilterBox = styled.div`
|
||||
color: ${({ theme }) => theme.colors.primary.dark1};
|
||||
text-align: left;
|
||||
padding: ${({ theme }) => theme.gridUnit * 2}px 0;
|
||||
margin: ${({ theme }) => theme.gridUnit * 3}px 0 0
|
||||
${({ theme }) => -theme.gridUnit * 2}px;
|
||||
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light1};
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.colors.primary.base};
|
||||
}
|
||||
`;
|
||||
|
||||
type FilterRemoval =
|
||||
| null
|
||||
| {
|
||||
isPending: true; // the filter sticks around for a moment before removal is finalized
|
||||
timerId: number; // id of the timer that finally removes the filter
|
||||
}
|
||||
| { isPending: false };
|
||||
|
||||
function generateFilterId() {
|
||||
return `NATIVE_FILTER-${shortid.generate()}`;
|
||||
}
|
||||
|
||||
export interface FilterConfigModalProps {
|
||||
isOpen: boolean;
|
||||
initialFilterId?: string;
|
||||
createNewOnOpen?: boolean;
|
||||
save: (filterConfig: FilterConfiguration) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const getFilterIds = (config: FilterConfiguration) =>
|
||||
config.map(filter => filter.id);
|
||||
|
||||
/**
|
||||
* This is the modal to configure all the dashboard-native filters.
|
||||
* Manages modal-level state, such as what filters are in the list,
|
||||
* and which filter is currently being edited.
|
||||
*
|
||||
* Calls the `save` callback with the new FilterConfiguration object
|
||||
* when the user saves the filters.
|
||||
*/
|
||||
export function FilterConfigModal({
|
||||
isOpen,
|
||||
initialFilterId,
|
||||
createNewOnOpen,
|
||||
save,
|
||||
onCancel,
|
||||
}: FilterConfigModalProps) {
|
||||
const [form] = Form.useForm<NativeFiltersForm>();
|
||||
|
||||
// the filter config from redux state, this does not change until modal is closed.
|
||||
const filterConfig = useFilterConfiguration();
|
||||
const filterConfigMap = useFilterConfigMap();
|
||||
|
||||
// new filter ids belong to filters have been added during
|
||||
// this configuration session, and only exist in the form state until we submit.
|
||||
const [newFilterIds, setNewFilterIds] = useState<string[]>([]);
|
||||
|
||||
// store ids of filters that have been removed with the time they were removed
|
||||
// so that we can disappear them after a few secs.
|
||||
// filters are still kept in state until form is submitted.
|
||||
const [removedFilters, setRemovedFilters] = useState<
|
||||
Record<string, FilterRemoval>
|
||||
>({});
|
||||
|
||||
// brings back a filter that was previously removed ("Undo")
|
||||
const restoreFilter = useCallback(
|
||||
(id: string) => {
|
||||
const removal = removedFilters[id];
|
||||
// gotta clear the removal timeout to prevent the filter from getting deleted
|
||||
if (removal?.isPending) clearTimeout(removal.timerId);
|
||||
setRemovedFilters(current => ({ ...current, [id]: null }));
|
||||
},
|
||||
[removedFilters],
|
||||
);
|
||||
|
||||
// The full ordered set of ((original + new) - completely removed) filter ids
|
||||
// Use this as the canonical list of what filters are being configured!
|
||||
// This includes filter ids that are pending removal, so check for that.
|
||||
const filterIds = useMemo(
|
||||
() =>
|
||||
uniq([...getFilterIds(filterConfig), ...newFilterIds]).filter(
|
||||
id => !removedFilters[id] || removedFilters[id]?.isPending,
|
||||
),
|
||||
[filterConfig, newFilterIds, removedFilters],
|
||||
);
|
||||
|
||||
// open the first filter in the list to start
|
||||
const getInitialCurrentFilterId = useCallback(
|
||||
() => initialFilterId ?? filterIds[0],
|
||||
[initialFilterId, filterIds],
|
||||
);
|
||||
const [currentFilterId, setCurrentFilterId] = useState(
|
||||
getInitialCurrentFilterId,
|
||||
);
|
||||
|
||||
// the form values are managed by the antd form, but we copy them to here
|
||||
// so that we can display them (e.g. filter titles in the tab headers)
|
||||
const [formValues, setFormValues] = useState<NativeFiltersForm>({
|
||||
filters: {},
|
||||
});
|
||||
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
// if the currently viewed filter is fully removed, change to another tab
|
||||
const currentFilterRemoved = removedFilters[currentFilterId];
|
||||
if (currentFilterRemoved && !currentFilterRemoved.isPending) {
|
||||
const nextFilterIndex = findLastIndex(
|
||||
filterIds,
|
||||
id => !removedFilters[id] && id !== currentFilterId,
|
||||
);
|
||||
if (nextFilterIndex !== -1)
|
||||
setCurrentFilterId(filterIds[nextFilterIndex]);
|
||||
}
|
||||
}, [currentFilterId, removedFilters, filterIds]);
|
||||
|
||||
// generates a new filter id and appends it to the newFilterIds
|
||||
const addFilter = useCallback(() => {
|
||||
const newFilterId = generateFilterId();
|
||||
setNewFilterIds([...newFilterIds, newFilterId]);
|
||||
setCurrentFilterId(newFilterId);
|
||||
}, [newFilterIds, setCurrentFilterId]);
|
||||
|
||||
// if this is a "create" modal rather than an "edit" modal,
|
||||
// add a filter on modal open
|
||||
useEffect(() => {
|
||||
if (createNewOnOpen && isOpen && !wasOpen) {
|
||||
addFilter();
|
||||
}
|
||||
}, [createNewOnOpen, isOpen, wasOpen, addFilter]);
|
||||
|
||||
// After this, it should be as if the modal was just opened fresh.
|
||||
// Called when the modal is closed.
|
||||
const resetForm = useCallback(() => {
|
||||
form.resetFields();
|
||||
setNewFilterIds([]);
|
||||
setCurrentFilterId(getInitialCurrentFilterId());
|
||||
setRemovedFilters({});
|
||||
}, [form, getInitialCurrentFilterId]);
|
||||
|
||||
const completeFilterRemoval = (filterId: string) => {
|
||||
// the filter state will actually stick around in the form,
|
||||
// and the filterConfig/newFilterIds, but we use removedFilters
|
||||
// to mark it as removed.
|
||||
setRemovedFilters(removedFilters => ({
|
||||
...removedFilters,
|
||||
[filterId]: { isPending: false },
|
||||
}));
|
||||
};
|
||||
|
||||
function onTabEdit(filterId: string, action: 'add' | 'remove') {
|
||||
if (action === 'remove') {
|
||||
// first set up the timer to completely remove it
|
||||
const timerId = window.setTimeout(
|
||||
() => completeFilterRemoval(filterId),
|
||||
REMOVAL_DELAY_SECS * 1000,
|
||||
);
|
||||
// mark the filter state as "removal in progress"
|
||||
setRemovedFilters(removedFilters => ({
|
||||
...removedFilters,
|
||||
[filterId]: { isPending: true, timerId },
|
||||
}));
|
||||
} else if (action === 'add') {
|
||||
addFilter();
|
||||
}
|
||||
}
|
||||
|
||||
function getFilterTitle(id: string) {
|
||||
return (
|
||||
formValues.filters[id]?.name ?? filterConfigMap[id]?.name ?? 'New Filter'
|
||||
);
|
||||
}
|
||||
|
||||
function getParentFilters(id: string) {
|
||||
return filterIds
|
||||
.filter(filterId => filterId !== id && !removedFilters[filterId])
|
||||
.map(id => ({
|
||||
id,
|
||||
title: getFilterTitle(id),
|
||||
}));
|
||||
}
|
||||
|
||||
const addValidationError = (
|
||||
filterId: string,
|
||||
field: string,
|
||||
error: string,
|
||||
) => {
|
||||
const fieldError = {
|
||||
name: ['filters', filterId, field],
|
||||
errors: [error],
|
||||
};
|
||||
form.setFields([fieldError]);
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw { errorFields: [fieldError] };
|
||||
};
|
||||
|
||||
const validateForm = useCallback(async () => {
|
||||
try {
|
||||
const formValues = (await form.validateFields()) as NativeFiltersForm;
|
||||
|
||||
const validateInstant = (filterId: string) => {
|
||||
const isInstant = formValues.filters[filterId]
|
||||
? formValues.filters[filterId].isInstant
|
||||
: filterConfigMap[filterId]?.isInstant;
|
||||
if (!isInstant) {
|
||||
addValidationError(
|
||||
filterId,
|
||||
'isInstant',
|
||||
'For parent filters changes must be applied instantly',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateCycles = (filterId: string, trace: string[] = []) => {
|
||||
if (trace.includes(filterId)) {
|
||||
addValidationError(
|
||||
filterId,
|
||||
'parentFilter',
|
||||
'Cannot create cyclic hierarchy',
|
||||
);
|
||||
}
|
||||
const parentId = formValues.filters[filterId]
|
||||
? formValues.filters[filterId].parentFilter?.value
|
||||
: filterConfigMap[filterId]?.cascadeParentIds?.[0];
|
||||
if (parentId) {
|
||||
validateInstant(parentId);
|
||||
validateCycles(parentId, [...trace, filterId]);
|
||||
}
|
||||
};
|
||||
|
||||
filterIds
|
||||
.filter(id => !removedFilters[id])
|
||||
.forEach(filterId => validateCycles(filterId));
|
||||
|
||||
return formValues;
|
||||
} catch (error) {
|
||||
console.warn('Filter Configuration Failed:', error);
|
||||
|
||||
if (!error.errorFields || !error.errorFields.length) return null; // not a validation error
|
||||
|
||||
// the name is in array format since the fields are nested
|
||||
type ErrorFields = { name: ['filters', string, string] }[];
|
||||
const errorFields = error.errorFields as ErrorFields;
|
||||
// filter id is the second item in the field name
|
||||
if (!errorFields.some(field => field.name[1] === currentFilterId)) {
|
||||
// switch to the first tab that had a validation error
|
||||
const filterError = errorFields.find(
|
||||
field => field.name[0] === 'filters',
|
||||
);
|
||||
if (filterError) {
|
||||
setCurrentFilterId(filterError.name[1]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [form, currentFilterId, filterConfigMap, filterIds, removedFilters]);
|
||||
|
||||
const onOk = useCallback(async () => {
|
||||
const values: NativeFiltersForm | null = await validateForm();
|
||||
if (values == null) return;
|
||||
|
||||
const newFilterConfig: FilterConfiguration = filterIds
|
||||
.filter(id => !removedFilters[id])
|
||||
.map(id => {
|
||||
// create a filter config object from the form inputs
|
||||
const formInputs = values.filters[id];
|
||||
// if user didn't open a filter, return the original config
|
||||
if (!formInputs) return filterConfigMap[id];
|
||||
return {
|
||||
id,
|
||||
name: formInputs.name,
|
||||
type: 'text',
|
||||
// for now there will only ever be one target
|
||||
targets: [
|
||||
{
|
||||
datasetId: formInputs.dataset.value,
|
||||
column: {
|
||||
name: formInputs.column,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultValue: formInputs.defaultValue || null,
|
||||
cascadeParentIds: formInputs.parentFilter
|
||||
? [formInputs.parentFilter.value]
|
||||
: [],
|
||||
scope: {
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
excluded: [],
|
||||
},
|
||||
inverseSelection: !!formInputs.inverseSelection,
|
||||
isInstant: !!formInputs.isInstant,
|
||||
allowsMultipleValues: !!formInputs.allowsMultipleValues,
|
||||
isRequired: !!formInputs.isRequired,
|
||||
};
|
||||
});
|
||||
|
||||
await save(newFilterConfig);
|
||||
resetForm();
|
||||
}, [
|
||||
save,
|
||||
resetForm,
|
||||
filterIds,
|
||||
removedFilters,
|
||||
filterConfigMap,
|
||||
validateForm,
|
||||
]);
|
||||
|
||||
const handleCancel = () => {
|
||||
resetForm();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
visible={isOpen}
|
||||
title={t('Filter Configuration and Scoping')}
|
||||
width="55%"
|
||||
onCancel={handleCancel}
|
||||
onOk={onOk}
|
||||
centered
|
||||
data-test="filter-modal"
|
||||
footer={[
|
||||
<Button key="cancel" buttonStyle="secondary" onClick={handleCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>,
|
||||
<Button key="submit" buttonStyle="primary" onClick={onOk}>
|
||||
{t('Save')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<StyledModalBody>
|
||||
<StyledForm
|
||||
form={form}
|
||||
onValuesChange={(changes, values: NativeFiltersForm) => {
|
||||
if (
|
||||
changes.filters &&
|
||||
Object.values(changes.filters).some(
|
||||
(filter: any) => filter.name != null,
|
||||
)
|
||||
) {
|
||||
// we only need to set this if a name changed
|
||||
setFormValues(values);
|
||||
}
|
||||
}}
|
||||
layout="vertical"
|
||||
>
|
||||
<FilterTabs
|
||||
tabPosition="left"
|
||||
onChange={setCurrentFilterId}
|
||||
activeKey={currentFilterId}
|
||||
onEdit={onTabEdit}
|
||||
addIcon={
|
||||
<StyledAddFilterBox>
|
||||
<PlusOutlined /> <span>{t('Add Filter')}</span>
|
||||
</StyledAddFilterBox>
|
||||
}
|
||||
>
|
||||
{filterIds.map(id => (
|
||||
<LineEditableTabs.TabPane
|
||||
tab={
|
||||
<FilterTabTitle
|
||||
className={removedFilters[id] ? 'removed' : ''}
|
||||
>
|
||||
<div>
|
||||
{removedFilters[id]
|
||||
? t('(Removed)')
|
||||
: getFilterTitle(id)}
|
||||
</div>
|
||||
{removedFilters[id] && (
|
||||
<a
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => restoreFilter(id)}
|
||||
>
|
||||
{t('Undo?')}
|
||||
</a>
|
||||
)}
|
||||
</FilterTabTitle>
|
||||
}
|
||||
key={id}
|
||||
closeIcon={removedFilters[id] ? <></> : <DeleteFilled />}
|
||||
>
|
||||
<FilterConfigForm
|
||||
form={form}
|
||||
filterId={id}
|
||||
filterToEdit={filterConfigMap[id]}
|
||||
removed={!!removedFilters[id]}
|
||||
restore={restoreFilter}
|
||||
parentFilters={getParentFilters(id)}
|
||||
/>
|
||||
</LineEditableTabs.TabPane>
|
||||
))}
|
||||
</FilterTabs>
|
||||
</StyledForm>
|
||||
</StyledModalBody>
|
||||
</ErrorBoundary>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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, { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
// import shortid from 'shortid';
|
||||
import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters';
|
||||
import { FilterConfigModal } from './FilterConfigModal';
|
||||
import { FilterConfiguration } from './types';
|
||||
|
||||
export interface FCBProps {
|
||||
createNewOnOpen?: boolean;
|
||||
}
|
||||
|
||||
export const FilterConfigurationLink: React.FC<FCBProps> = ({
|
||||
createNewOnOpen,
|
||||
children,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
async function submit(filterConfig: FilterConfiguration) {
|
||||
await dispatch(setFilterConfiguration(filterConfig));
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div onClick={() => setOpen(true)}>{children}</div>
|
||||
<FilterConfigModal
|
||||
isOpen={isOpen}
|
||||
save={submit}
|
||||
onCancel={close}
|
||||
createNewOnOpen={createNewOnOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterConfigurationLink;
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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 { Button } from 'src/common/components';
|
||||
import Icon from 'src/components/Icon';
|
||||
import { useFilterConfiguration } from './state';
|
||||
|
||||
interface Args {
|
||||
filter: any;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface FiltersListProps {
|
||||
setEditFilter: (arg0: Args) => void;
|
||||
setDataset: (arg0: any) => void;
|
||||
}
|
||||
const FiltersStyle = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const FiltersList = ({ setEditFilter, setDataset }: FiltersListProps) => {
|
||||
const filterConfigs = useFilterConfiguration();
|
||||
<>
|
||||
{filterConfigs.map((filter, i: number) => (
|
||||
<FiltersStyle>
|
||||
<Button
|
||||
type="link"
|
||||
key={filter.name}
|
||||
onClick={() => {
|
||||
setEditFilter({ filter, index: i });
|
||||
setDataset(filter.targets[0].datasetId);
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</Button>
|
||||
<span
|
||||
role="button"
|
||||
title="Edit Dashboard"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</span>
|
||||
</FiltersStyle>
|
||||
))}
|
||||
</>;
|
||||
};
|
||||
|
||||
export default FiltersList;
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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, { FC, useState } from 'react';
|
||||
import { Tree } from 'src/common/components';
|
||||
import { useFilterScopeTree } from './state';
|
||||
import { DASHBOARD_ROOT_ID } from '../../util/constants';
|
||||
import { findFilterScope } from './utils';
|
||||
|
||||
type ScopingTreeProps = {
|
||||
setFilterScope: Function;
|
||||
};
|
||||
|
||||
const ScopingTree: FC<ScopingTreeProps> = ({ setFilterScope }) => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>([
|
||||
DASHBOARD_ROOT_ID,
|
||||
]);
|
||||
|
||||
const { treeData, layout } = useFilterScopeTree();
|
||||
|
||||
const [autoExpandParent, setAutoExpandParent] = useState<boolean>(true);
|
||||
const [checkedKeys, setCheckedKeys] = useState<string[]>([]);
|
||||
|
||||
const onExpand = (expandedKeys: string[]) => {
|
||||
setExpandedKeys(expandedKeys);
|
||||
setAutoExpandParent(false);
|
||||
};
|
||||
|
||||
const onCheck = (checkedKeys: string[]) => {
|
||||
setCheckedKeys(checkedKeys);
|
||||
setFilterScope(findFilterScope(checkedKeys, layout));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tree
|
||||
checkable
|
||||
selectable={false}
|
||||
onExpand={onExpand}
|
||||
expandedKeys={expandedKeys}
|
||||
autoExpandParent={autoExpandParent}
|
||||
onCheck={onCheck}
|
||||
checkedKeys={checkedKeys}
|
||||
treeData={treeData}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScopingTree;
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { setExtraFormData } from 'src/dashboard/actions/nativeFilters';
|
||||
import { getInitialFilterState } from 'src/dashboard/reducers/nativeFilters';
|
||||
import { ExtraFormData, t } from '@superset-ui/core';
|
||||
import { Charts, Layout, RootState } from 'src/dashboard/types';
|
||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||
import { DASHBOARD_ROOT_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
import {
|
||||
Filter,
|
||||
FilterConfiguration,
|
||||
FilterState,
|
||||
NativeFiltersState,
|
||||
TreeItem,
|
||||
} from './types';
|
||||
import { buildTree, mergeExtraFormData } from './utils';
|
||||
|
||||
const defaultFilterConfiguration: Filter[] = [];
|
||||
|
||||
export function useFilterConfiguration() {
|
||||
return useSelector<any, FilterConfiguration>(
|
||||
state =>
|
||||
state.dashboardInfo?.metadata?.filter_configuration ||
|
||||
defaultFilterConfiguration,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the dashboard's filter configuration,
|
||||
* converted into a map of id -> filter
|
||||
*/
|
||||
export function useFilterConfigMap() {
|
||||
const filterConfig = useFilterConfiguration();
|
||||
return useMemo(
|
||||
() =>
|
||||
filterConfig.reduce((acc: Record<string, Filter>, filter: Filter) => {
|
||||
acc[filter.id] = filter;
|
||||
return acc;
|
||||
}, {} as Record<string, Filter>),
|
||||
[filterConfig],
|
||||
);
|
||||
}
|
||||
|
||||
export function useFilterState(id: string) {
|
||||
return useSelector<any, FilterState>(state => {
|
||||
return state.nativeFilters.filtersState[id] || getInitialFilterState(id);
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetExtraFormData() {
|
||||
const dispatch = useDispatch();
|
||||
return useCallback(
|
||||
(id: string, extraFormData: ExtraFormData) =>
|
||||
dispatch(setExtraFormData(id, extraFormData)),
|
||||
[dispatch],
|
||||
);
|
||||
}
|
||||
|
||||
export function useFilterScopeTree(): {
|
||||
treeData: [TreeItem];
|
||||
layout: Layout;
|
||||
} {
|
||||
const layout = useSelector<RootState, Layout>(
|
||||
({ dashboardLayout: { present } }) => present,
|
||||
);
|
||||
|
||||
const charts = useSelector<RootState, Charts>(({ charts }) => charts);
|
||||
|
||||
const tree = {
|
||||
children: [],
|
||||
key: DASHBOARD_ROOT_ID,
|
||||
type: DASHBOARD_ROOT_TYPE,
|
||||
title: t('All Panels'),
|
||||
};
|
||||
buildTree(layout[DASHBOARD_ROOT_ID], tree, layout, charts);
|
||||
return { treeData: [tree], layout };
|
||||
}
|
||||
|
||||
export function useCascadingFilters(id: string) {
|
||||
return useSelector<any, ExtraFormData>(state => {
|
||||
const { nativeFilters }: { nativeFilters: NativeFiltersState } = state;
|
||||
const { filters, filtersState } = nativeFilters;
|
||||
const filter = filters[id];
|
||||
const cascadeParentIds = filter?.cascadeParentIds ?? [];
|
||||
let cascadedFilters = {};
|
||||
cascadeParentIds.forEach(parentId => {
|
||||
const parentState = filtersState[parentId] || {};
|
||||
const { extraFormData: parentExtra = {} } = parentState;
|
||||
cascadedFilters = mergeExtraFormData(cascadedFilters, parentExtra);
|
||||
});
|
||||
return cascadedFilters;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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 { ExtraFormData, QueryObjectFilterClause } from '@superset-ui/core';
|
||||
|
||||
export enum Scoping {
|
||||
all,
|
||||
specific,
|
||||
}
|
||||
|
||||
interface NativeFiltersFormItem {
|
||||
scoping: Scoping;
|
||||
scope: Scope;
|
||||
name: string;
|
||||
dataset: {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
column: string;
|
||||
defaultValue: string;
|
||||
parentFilter: {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
inverseSelection: boolean;
|
||||
isInstant: boolean;
|
||||
allowsMultipleValues: boolean;
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
export interface NativeFiltersForm {
|
||||
filters: Record<string, NativeFiltersFormItem>;
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface Scope {
|
||||
rootPath: string[];
|
||||
excluded: number[];
|
||||
}
|
||||
|
||||
/** The target of a filter is the datasource/column being filtered */
|
||||
export interface Target {
|
||||
datasetId: number;
|
||||
column: Column;
|
||||
|
||||
// maybe someday support this?
|
||||
// show values from these columns in the filter options selector
|
||||
// clarityColumns?: Column[];
|
||||
}
|
||||
|
||||
export type FilterType = 'text' | 'date';
|
||||
|
||||
/**
|
||||
* This is a filter configuration object, stored in the dashboard's json metadata.
|
||||
* The values here do not reflect the current state of the filter.
|
||||
*/
|
||||
export interface Filter {
|
||||
allowsMultipleValues: boolean;
|
||||
cascadeParentIds: string[];
|
||||
defaultValue: string | null;
|
||||
currentValue?: (string | number | boolean)[] | null;
|
||||
inverseSelection: boolean;
|
||||
isInstant: boolean;
|
||||
isRequired: boolean;
|
||||
id: string; // randomly generated at filter creation
|
||||
name: string;
|
||||
scope: Scope;
|
||||
type: FilterType;
|
||||
// for now there will only ever be one target
|
||||
// when multiple targets are supported, change this to Target[]
|
||||
targets: [Target];
|
||||
}
|
||||
|
||||
export interface CascadeFilter extends Filter {
|
||||
cascadeChildren: CascadeFilter[];
|
||||
}
|
||||
|
||||
export type FilterConfiguration = Filter[];
|
||||
|
||||
export type SelectedValues = string[] | null;
|
||||
|
||||
/** Current state of the filter, stored in `nativeFilters` in redux */
|
||||
export type FilterState = {
|
||||
id: string; // ties this filter state to the config object
|
||||
extraFormData?: ExtraFormData;
|
||||
};
|
||||
|
||||
export type AllFilterState = {
|
||||
column: Column;
|
||||
datasetId: number;
|
||||
datasource: string;
|
||||
id: string;
|
||||
selectedValues: SelectedValues;
|
||||
filterClause?: QueryObjectFilterClause;
|
||||
};
|
||||
|
||||
/** UI Ant tree type */
|
||||
export type TreeItem = {
|
||||
children: TreeItem[];
|
||||
key: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type NativeFiltersState = {
|
||||
filters: {
|
||||
[filterId: string]: Filter;
|
||||
};
|
||||
filtersState: {
|
||||
[filterId: string]: FilterState;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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 { ExtraFormData, QueryObject } from '@superset-ui/core';
|
||||
import { Charts, Layout, LayoutItem } from 'src/dashboard/types';
|
||||
import {
|
||||
CHART_TYPE,
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
TABS_TYPE,
|
||||
TAB_TYPE,
|
||||
} from 'src/dashboard/util/componentTypes';
|
||||
import {
|
||||
CascadeFilter,
|
||||
Filter,
|
||||
NativeFiltersState,
|
||||
Scope,
|
||||
TreeItem,
|
||||
} from './types';
|
||||
|
||||
export const isShowTypeInTree = ({ type, meta }: LayoutItem, charts?: Charts) =>
|
||||
(type === TABS_TYPE ||
|
||||
type === TAB_TYPE ||
|
||||
type === CHART_TYPE ||
|
||||
type === DASHBOARD_ROOT_TYPE) &&
|
||||
(!charts || charts[meta?.chartId]?.formData?.viz_type !== 'filter_box');
|
||||
|
||||
export const buildTree = (
|
||||
node: LayoutItem,
|
||||
treeItem: TreeItem,
|
||||
layout: Layout,
|
||||
charts: Charts,
|
||||
) => {
|
||||
let itemToPass: TreeItem = treeItem;
|
||||
if (isShowTypeInTree(node, charts) && node.type !== DASHBOARD_ROOT_TYPE) {
|
||||
const currentTreeItem = {
|
||||
key: node.id,
|
||||
title: node.meta.sliceName || node.meta.text || node.id.toString(),
|
||||
children: [],
|
||||
};
|
||||
treeItem.children.push(currentTreeItem);
|
||||
itemToPass = currentTreeItem;
|
||||
}
|
||||
node.children.forEach(child =>
|
||||
buildTree(layout[child], itemToPass, layout, charts),
|
||||
);
|
||||
};
|
||||
|
||||
export const findFilterScope = (
|
||||
checkedKeys: string[],
|
||||
layout: Layout,
|
||||
): Scope => {
|
||||
if (!checkedKeys.length) {
|
||||
return {
|
||||
rootPath: [],
|
||||
excluded: [],
|
||||
};
|
||||
}
|
||||
const checkedItemParents = checkedKeys.map(key =>
|
||||
(layout[key].parents || []).filter(parent =>
|
||||
isShowTypeInTree(layout[parent]),
|
||||
),
|
||||
);
|
||||
checkedItemParents.sort((p1, p2) => p1.length - p2.length);
|
||||
const rootPath = checkedItemParents.map(
|
||||
parents => parents[checkedItemParents[0].length - 1],
|
||||
);
|
||||
|
||||
const excluded: number[] = [];
|
||||
const isExcluded = (parent: string, item: string) =>
|
||||
rootPath.includes(parent) && !checkedKeys.includes(item);
|
||||
|
||||
Object.entries(layout).forEach(([key, value]) => {
|
||||
if (
|
||||
value.type === CHART_TYPE &&
|
||||
value.parents?.find(parent => isExcluded(parent, key))
|
||||
) {
|
||||
excluded.push(value.meta.chartId);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
rootPath: [...new Set(rootPath)],
|
||||
excluded,
|
||||
};
|
||||
};
|
||||
|
||||
export function mergeExtraFormData(
|
||||
originalExtra: ExtraFormData,
|
||||
newExtra: ExtraFormData,
|
||||
): ExtraFormData {
|
||||
const {
|
||||
override_form_data: originalOverride = {},
|
||||
append_form_data: originalAppend = {},
|
||||
} = originalExtra;
|
||||
const {
|
||||
override_form_data: newOverride = {},
|
||||
append_form_data: newAppend = {},
|
||||
} = newExtra;
|
||||
|
||||
const appendKeys = new Set([
|
||||
...Object.keys(originalAppend),
|
||||
...Object.keys(newAppend),
|
||||
]);
|
||||
const appendFormData: Partial<QueryObject> = {};
|
||||
appendKeys.forEach(key => {
|
||||
appendFormData[key] = [
|
||||
// @ts-ignore
|
||||
...(originalAppend[key] || []),
|
||||
// @ts-ignore
|
||||
...(newAppend[key] || []),
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
override_form_data: {
|
||||
...originalOverride,
|
||||
...newOverride,
|
||||
},
|
||||
append_form_data: appendFormData,
|
||||
};
|
||||
}
|
||||
|
||||
export function getExtraFormData(
|
||||
nativeFilters: NativeFiltersState,
|
||||
): ExtraFormData {
|
||||
let extraFormData: ExtraFormData = {};
|
||||
Object.keys(nativeFilters.filters).forEach(key => {
|
||||
const filterState = nativeFilters.filtersState[key] || {};
|
||||
const { extraFormData: newExtra = {} } = filterState;
|
||||
extraFormData = mergeExtraFormData(extraFormData, newExtra);
|
||||
});
|
||||
return extraFormData;
|
||||
}
|
||||
|
||||
export function mapParentFiltersToChildren(
|
||||
filters: Filter[],
|
||||
): { [id: string]: Filter[] } {
|
||||
const cascadeChildren = {};
|
||||
filters.forEach(filter => {
|
||||
const [parentId] = filter.cascadeParentIds || [];
|
||||
if (parentId) {
|
||||
if (!cascadeChildren[parentId]) {
|
||||
cascadeChildren[parentId] = [];
|
||||
}
|
||||
cascadeChildren[parentId].push(filter);
|
||||
}
|
||||
});
|
||||
return cascadeChildren;
|
||||
}
|
||||
|
||||
export function buildCascadeFiltersTree(filters: Filter[]): CascadeFilter[] {
|
||||
const cascadeChildren = mapParentFiltersToChildren(filters);
|
||||
|
||||
const getCascadeFilter = (filter: Filter): CascadeFilter => {
|
||||
const children = cascadeChildren[filter.id] || [];
|
||||
return {
|
||||
...filter,
|
||||
cascadeChildren: children.map(getCascadeFilter),
|
||||
};
|
||||
};
|
||||
|
||||
return filters
|
||||
.filter(filter => !filter.cascadeParentIds?.length)
|
||||
.map(getCascadeFilter);
|
||||
}
|
||||
@@ -45,29 +45,35 @@ function mapStateToProps(
|
||||
dashboardState,
|
||||
datasources,
|
||||
sliceEntities,
|
||||
nativeFilters,
|
||||
},
|
||||
ownProps,
|
||||
) {
|
||||
const { id } = ownProps;
|
||||
const chart = chartQueries[id] || {};
|
||||
const datasource =
|
||||
(chart && chart.form_data && datasources[chart.form_data.datasource]) || {};
|
||||
const { colorScheme, colorNamespace } = dashboardState;
|
||||
|
||||
// note: this method caches filters if possible to prevent render cascades
|
||||
const formData = getFormDataWithExtraFilters({
|
||||
chart,
|
||||
filters: getAppliedFilterValues(id),
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
sliceId: id,
|
||||
nativeFilters,
|
||||
});
|
||||
|
||||
formData.dashboardId = dashboardInfo.id;
|
||||
|
||||
return {
|
||||
chart,
|
||||
datasource:
|
||||
(chart && chart.form_data && datasources[chart.form_data.datasource]) ||
|
||||
{},
|
||||
datasource,
|
||||
slice: sliceEntities.slices[id],
|
||||
timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
|
||||
filters: getActiveFilters() || EMPTY_FILTERS,
|
||||
// note: this method caches filters if possible to prevent render cascades
|
||||
formData: getFormDataWithExtraFilters({
|
||||
chart,
|
||||
filters: getAppliedFilterValues(id),
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
sliceId: id,
|
||||
}),
|
||||
formData,
|
||||
editMode: dashboardState.editMode,
|
||||
isExpanded: !!dashboardState.expandedSlices[id],
|
||||
supersetCanExplore: !!dashboardInfo.superset_can_explore,
|
||||
|
||||
@@ -38,6 +38,7 @@ function mapStateToProps(state) {
|
||||
dashboardState,
|
||||
dashboardLayout,
|
||||
impressionId,
|
||||
nativeFilters,
|
||||
} = state;
|
||||
|
||||
return {
|
||||
@@ -56,6 +57,7 @@ function mapStateToProps(state) {
|
||||
activeFilters: getActiveFilters(),
|
||||
slices: sliceEntities.slices,
|
||||
layout: dashboardLayout.present,
|
||||
nativeFilters,
|
||||
impressionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,14 +29,13 @@ export interface FiltersBadgeProps {
|
||||
chartId: number;
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => {
|
||||
return bindActionCreators(
|
||||
const mapDispatchToProps = (dispatch: Dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
onHighlightFilterSource: setDirectPathToChild,
|
||||
},
|
||||
dispatch,
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (
|
||||
{ datasources, dashboardFilters, charts }: any,
|
||||
|
||||
@@ -22,6 +22,7 @@ import shortid from 'shortid';
|
||||
import { CategoricalColorNamespace } from '@superset-ui/core';
|
||||
|
||||
import { initSliceEntities } from 'src/dashboard/reducers/sliceEntities';
|
||||
import { getInitialState as getInitialNativeFilterState } from 'src/dashboard/reducers/nativeFilters';
|
||||
import { getParam } from 'src/modules/utils';
|
||||
import { applyDefaultFormData } from 'src/explore/store';
|
||||
import { buildActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
@@ -168,7 +169,10 @@ export default function getInitialState(bootstrapData) {
|
||||
}
|
||||
|
||||
// build DashboardFilters for interactive filter features
|
||||
if (slice.form_data.viz_type === 'filter_box') {
|
||||
if (
|
||||
slice.form_data.viz_type === 'filter_box' ||
|
||||
slice.form_data.viz_type === 'filter_select'
|
||||
) {
|
||||
const configs = getFilterConfigsFromFormdata(slice.form_data);
|
||||
let { columns } = configs;
|
||||
const { labels } = configs;
|
||||
@@ -255,6 +259,10 @@ export default function getInitialState(bootstrapData) {
|
||||
directPathToChild.push(directLinkComponentId);
|
||||
}
|
||||
|
||||
const nativeFilters = getInitialNativeFilterState(
|
||||
dashboard.metadata.filter_configuration || [],
|
||||
);
|
||||
|
||||
return {
|
||||
datasources,
|
||||
sliceEntities: { ...initSliceEntities, slices, isLoading: false },
|
||||
@@ -277,6 +285,7 @@ export default function getInitialState(bootstrapData) {
|
||||
lastModifiedTime: dashboard.last_modified_time,
|
||||
},
|
||||
dashboardFilters,
|
||||
nativeFilters,
|
||||
dashboardState: {
|
||||
sliceIds: Array.from(sliceIds),
|
||||
directPathToChild,
|
||||
|
||||
@@ -22,6 +22,7 @@ import charts from '../../chart/chartReducer';
|
||||
import dashboardInfo from './dashboardInfo';
|
||||
import dashboardState from './dashboardState';
|
||||
import dashboardFilters from './dashboardFilters';
|
||||
import nativeFilters from './nativeFilters';
|
||||
import datasources from './datasources';
|
||||
import sliceEntities from './sliceEntities';
|
||||
import dashboardLayout from './undoableDashboardLayout';
|
||||
@@ -34,6 +35,7 @@ export default combineReducers({
|
||||
datasources,
|
||||
dashboardInfo,
|
||||
dashboardFilters,
|
||||
nativeFilters,
|
||||
dashboardState,
|
||||
dashboardLayout,
|
||||
impressionId,
|
||||
|
||||
76
superset-frontend/src/dashboard/reducers/nativeFilters.ts
Normal file
76
superset-frontend/src/dashboard/reducers/nativeFilters.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 {
|
||||
SET_EXTRA_FORM_DATA,
|
||||
AnyFilterAction,
|
||||
SET_FILTER_CONFIG_COMPLETE,
|
||||
} from 'src/dashboard/actions/nativeFilters';
|
||||
import {
|
||||
FilterConfiguration,
|
||||
FilterState,
|
||||
NativeFiltersState,
|
||||
} from 'src/dashboard/components/nativeFilters/types';
|
||||
|
||||
export function getInitialFilterState(id: string): FilterState {
|
||||
return {
|
||||
id,
|
||||
extraFormData: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function getInitialState(
|
||||
filterConfig: FilterConfiguration,
|
||||
): NativeFiltersState {
|
||||
const filters = {};
|
||||
const filtersState = {};
|
||||
const state = { filters, filtersState };
|
||||
filterConfig.forEach(filter => {
|
||||
const { id } = filter;
|
||||
filters[id] = filter;
|
||||
filtersState[id] = getInitialFilterState(id);
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
export default function nativeFilterReducer(
|
||||
state: NativeFiltersState = { filters: {}, filtersState: {} },
|
||||
action: AnyFilterAction,
|
||||
) {
|
||||
const { filters, filtersState } = state;
|
||||
switch (action.type) {
|
||||
case SET_EXTRA_FORM_DATA:
|
||||
return {
|
||||
filters,
|
||||
filtersState: {
|
||||
...filtersState,
|
||||
[action.filterId]: {
|
||||
...filtersState[action.filterId],
|
||||
extraFormData: action.extraFormData,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case SET_FILTER_CONFIG_COMPLETE:
|
||||
return getInitialState(action.filterConfig);
|
||||
|
||||
// TODO handle SET_FILTER_CONFIG_FAIL action
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,9 @@
|
||||
.dashboard {
|
||||
position: relative;
|
||||
color: @almost-black;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: @lightest;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
box-shadow: 0 4px 4px 0 fade(@darkest, @opacity-light); /* @TODO color */
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* only top-level tabs have popover, give it more padding to match header + tabs */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user