Compare commits

...

39 Commits

Author SHA1 Message Date
Rob DiCiuccio
9a861af7ac fix: Stabilize and deprecate legacy alerts module (#12627)
* Add deprecation notice to superset.tasks.schedules

* Reduce retries and schedule window for alerts

(cherry picked from commit e4ae17def5)
2021-01-25 13:32:12 -08:00
Michael S. Molina
c8343a637f Fix list filters vertical alignment (#12497) 2021-01-15 18:50:51 +02:00
Jesse Yang
66a0d9088b fix(explore): Add Time section back to FilterBox (#12537) 2021-01-15 18:49:04 +02:00
Jesse Yang
1a7c0beb48 fix(explore): time table control panel (#12532) 2021-01-15 18:48:34 +02:00
Daniel Vaz Gaspar
1fe221422a fix: impose dataset ownership check on old API (#12491)
* fix: impose dataset ownership check on old API

* update UPDATING.md

* partially protect the old MVC also

* prevent metric and column add and update
2021-01-15 18:47:13 +02:00
Beto Dealmeida
b21c90d780 fix: import ZIP files that have been modified (#12425)
* fix: import ZIP files that have been modified

* Add unit test
2021-01-15 18:44:33 +02:00
Beto Dealmeida
53169d2459 fix (SQL Lab): disappearing results on tab switch (#12472)
* fix (SQL Lab): disappearing results on tab switch

* Remove state

* Fix test
2021-01-15 18:43:48 +02:00
Yongjie Zhao
b6e8e595f7 fix(timepicker): make pyparsing thread safe (#12489)
* fix: make pyparsing thread safe

* remove parenthesis for decorator
2021-01-13 17:35:44 +02:00
Yongjie Zhao
28e099a312 refactor: from superset.utils.core break down date_parser (#12408) 2021-01-13 17:35:20 +02:00
Beto Dealmeida
60781a05d6 fix: SQL Lab vertical space (#12187)
(cherry picked from commit de61859e98)
2021-01-11 12:32:20 -08:00
Ville Brofeldt
617d6e51a9 silence two tests 2021-01-07 22:12:05 +02:00
Ville Brofeldt
aa9622c272 fix black linting error 2021-01-07 21:38:53 +02:00
Ville Brofeldt
70e83e811c feat(chart-data-api): ignore unknown fields on QueryObject (#12118) 2021-01-07 21:35:17 +02:00
Kamil Gabryjelski
722e2ea204 fix(explore): bugs in Custom SQL editor in filter popover (#12278)
* Fix Save button disabled until clause is switched

* Fix 'undefined undefined' when sql query is empty

* fix test

Co-authored-by: Ville Brofeldt <ville.v.brofeldt@gmail.com>
2021-01-07 20:56:16 +02:00
Maxwell Amante
0b0891cf1c chore(home): add ErrorBoundary to Charts section (#12239) 2021-01-07 20:55:58 +02:00
Ville Brofeldt
cd8906c181 fix(explore): bump superset-ui to 0.16.3 and fix DeckGL bug (#12242) 2021-01-07 20:55:44 +02:00
Jesse Yang
58d577989f refactor: remove queryFields in QueryObject and update chart control configs (#12091)
* Clean up queryFields

* Clean up unused vars

* Bump chart plugins

* Bringing changes in #12147
2021-01-07 20:54:34 +02:00
Yongjie Zhao
d6d4eb7644 feat(explore): Time picker enhancement follow up (#12208)
* refactor layout

* WIP

* fix typing

* styling

* fix lint

* frontend IT

* rename variable

* added quarter

* typos

* refine code structure
2021-01-07 20:50:12 +02:00
Kamil Gabryjelski
33bfa90086 fix: Altered button crashing and title not updating on Explore view (#11960)
* Fix chart name not updating

* Fix AlteredSliceTag crashing

* Update name after it's changed in another modal
2021-01-07 20:50:02 +02:00
Kamil Gabryjelski
2085b4c6dc Refetch samples only when filters change (#11999) 2021-01-07 20:49:50 +02:00
Kamil Gabryjelski
9ac25898fb feat: Implement drag & drop for metrics and filters labels (#12184) 2021-01-07 20:49:43 +02:00
Evan Rusackas
098ea0c850 feat: Native filters fast-follow (#12138)
* wip: filter create modal

* add a feature flag

* automatic changes to package lock

* wip

* filter sidebar and basic state management

* move create button to the sidebar

* first step for edit filterconfig

* partially fix tests...

* edits to types and comments

* respect feature flag on the filter sidebar

* add filterconfig form

* get input state working

* feat: tree filter scopes UI

* fix: turn on flag

* sticky filter bar

* stop preferring default export

* feat: finish filter scoping

* fix: under toggle

* fix: title

* fix: add licence

* refactor: update TS

* fix: fix on reopen modal + validation

* new filter bar menu

* adding, but commenting out, bulk scoping action

* adding some placeholder buttons and styles therefor

* feat: add filter chart

* add relative path to package.json

* update modal

* a little input styling... just getting warmed up

* Revert "feat: add filter chart"

This reverts commit b1302d35b6.

* Revert "add relative path to package.json"

This reverts commit 26a7b40e18.

* https package lock idk

* feat: add filter chart

* add relative path to package.json

* flexboxes all the way down

* dynamically generate groupby and datasource in select control

* big wip

* fix target column name

* no importing nonexistent things

* styles and name editing

* Add hook for retrieval of all filter states

* start with a new filter when clicking add filter

* handle removed filters gracefully

* fix incorrect default filter configuration

* add fields to useAllFilterState

* add redux for filterconfigs

* add support for native_filters

* remove consoles

* improve filter removal

* unbreak infinite loop

* basic sidebar toggling working!

* collapsing and menu working more smoothly

* linting

* make dataset and column inputs work

* save filter values properly

* add dashboard event for filter updates

* guarded

* apply filters properly

* fix schema

* making New Filter button a link

* gridunits ftw

* centering modal

* tis not a button anymore! nixing type.

* plus and collapse buttons instead of "more" menu

* updating full size filter icons

* adding icons to filter collapsing/expanding

* turning off animation, but leaving class-based animation css

* fix linting error

* fix native filters for legacy charts

* updates test

* no individual apply buttons

* fix bugs with filter config modal

* remove redundant code

* switch to the filter with validation errors on submit

* separate form validation

* switch config button from add to edit

* switch to the filter with validation errors on submit

* separate form validation

* switch config button from add to edit

* update tests

* oops forgot to add the fancy new useChangeEffect hook

* comments and code reorganization

* rename native_filters to extr_form_data and move hook

* disable native filters in viz selector

* add cascading

* implement new extra form data api

* cleanup

* updates tests

* bump npm packages

* fix bad merge on package.json + lock

* lint

* replace in and not in with uppercase

* lint

* lint

* lint

* lint

* bulk test fix

* Sort select input alphabetically

* Change type for sorting elements

* sleeker filter removal UX

* fix rest of unit tests

* make filter operators all uppercase

* Hide Filter bar when there are no filters

* Show edit button for dashboard owners only

* Add visible argument to filters toggle function to avoid future regression

* Improve Toggle filters bar function

* lint

* fix js lint + set createNewOnOpen

* Handle setting extra form data in Filter Bar instead of Filter Control

* Add Handle apply filter function to Apply button

* Allow applying changes instantly

* Fix types

* remove console logs

* fix package

* Add Error Boundary component to Filter bar and Filter Config Modal

* fix jest tests

* update native filters tests to pass

* reset cypress baseUrl

* remove unnecessary field

* Add Parent Filter input field to Config Modal

* Create Cascade Filter & display children filters

* Add Cascade Popover

* Display Filter value both in Filter Bar and in Cascade Popover

* Display the youngest filter value label in the Filter bar

* Add styles to Cascade Popover and filters

* Force to apply changes instantly for parent filters and refactor styles

* Show error for no cyclical hierarchy and refactor

* Add validation for parent filter to be applied instantly

* Add Error Boundary to Filter Config Modal

* cleanup: remove unused state fields

* move unrelated types to an appropriate location

* remove misplaced resource fetch error logic

* fix cascadeParentIds error

* fix cypress password

* initial attempt at fixing scope issue

* fix bad merge

* fix lint

* trying out makeApi for saving filters

* remove unused import

* fix test

* silence bad test

* Improve styling of Filter Config Modal

* Improve styles for whole native filters feature

* Add styles for active filter tab

* Fix text for scoping

* Clean up Filter Bar and Config Modal styles

* Remove fractional gridUnits. Change name for CheckboxFormItem. Add placeholder to Parent Filter select.

* Remove unnecessary button size for Config Modal

* add native-filter feat flag config

* oops fix here

* remove space

* Update superset-frontend/src/common/components/index.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts

Co-authored-by: Evan Rusackas <evan@preset.io>

* Add Cache Wrapper helper to avoid datasets requests deduplication

* Add license to new Cache Wrapper helper

* Add Cache Wrapper tests

* Fix expanding Filter Bar

* use styledMount in tests

* comment

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/dashboard/components/nativeFilters/FilterBar.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/dashboard/components/nativeFilters/FilterConfigurationLink.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* address PR feedback

* fix package lock

* null guards

* Fix charts resizing

* removing emotion/react and going old school on css animation

* fxing css glitch on scoping disclaimer

* src paths.

* using gridUnits

* nixing unnecessary diamonds

* linting

* fix type errors

* Inverting collapsed icons... closer to data src selector design

* restoring feature flag to proper default setting

* missing condition

* fix tests

* patching test

* just a button

* flaky tests

Co-authored-by: David Aaron Suddjian <aasuddjian@gmail.com>
Co-authored-by: Phillip Kelley-Dotson <pkelleydotson@yahoo.com>
Co-authored-by: Simcha Shats <simcha.shats@nielsen.com>
Co-authored-by: amitNielsen <amit.miran@nielsen.com>
Co-authored-by: Ville Brofeldt <ville.v.brofeldt@gmail.com>
Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>
Co-authored-by: Agata Stawarz-Pastewska <agata.stawarz-pastewska@polidea.com>
Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
2021-01-07 20:49:32 +02:00
David Aaron Suddjian
c69b517bfc feat(dashboard): Dashboard-Native Filters (#11814)
* wip: filter create modal

* add a feature flag

* automatic changes to package lock

* wip

* filter sidebar and basic state management

* move create button to the sidebar

* first step for edit filterconfig

* partially fix tests...

* edits to types and comments

* respect feature flag on the filter sidebar

* add filterconfig form

* get input state working

* feat: tree filter scopes UI

* fix: turn on flag

* sticky filter bar

* stop preferring default export

* feat: finish filter scoping

* fix: under toggle

* fix: title

* fix: add licence

* refactor: update TS

* fix: fix on reopen modal + validation

* new filter bar menu

* adding, but commenting out, bulk scoping action

* adding some placeholder buttons and styles therefor

* feat: add filter chart

* add relative path to package.json

* update modal

* a little input styling... just getting warmed up

* Revert "feat: add filter chart"

This reverts commit b1302d35b6.

* Revert "add relative path to package.json"

This reverts commit 26a7b40e18.

* https package lock idk

* feat: add filter chart

* add relative path to package.json

* flexboxes all the way down

* dynamically generate groupby and datasource in select control

* big wip

* fix target column name

* no importing nonexistent things

* styles and name editing

* Add hook for retrieval of all filter states

* start with a new filter when clicking add filter

* handle removed filters gracefully

* fix incorrect default filter configuration

* add fields to useAllFilterState

* add redux for filterconfigs

* add support for native_filters

* remove consoles

* improve filter removal

* unbreak infinite loop

* basic sidebar toggling working!

* collapsing and menu working more smoothly

* linting

* make dataset and column inputs work

* save filter values properly

* add dashboard event for filter updates

* guarded

* apply filters properly

* fix schema

* making New Filter button a link

* gridunits ftw

* centering modal

* tis not a button anymore! nixing type.

* plus and collapse buttons instead of "more" menu

* updating full size filter icons

* adding icons to filter collapsing/expanding

* turning off animation, but leaving class-based animation css

* fix linting error

* fix native filters for legacy charts

* updates test

* no individual apply buttons

* fix bugs with filter config modal

* remove redundant code

* switch to the filter with validation errors on submit

* separate form validation

* switch config button from add to edit

* update tests

* oops forgot to add the fancy new useChangeEffect hook

* comments and code reorganization

* rename native_filters to extr_form_data and move hook

* disable native filters in viz selector

* add cascading

* implement new extra form data api

* cleanup

* updates tests

* bump npm packages

* fix bad merge on package.json + lock

* lint

* replace in and not in with uppercase

* lint

* lint

* lint

* lint

* bulk test fix

* Sort select input alphabetically

* Change type for sorting elements

* fix rest of unit tests

* make filter operators all uppercase

* Hide Filter bar when there are no filters

* Show edit button for dashboard owners only

* Add visible argument to filters toggle function to avoid future regression

* Improve Toggle filters bar function

* lint

* fix js lint + set createNewOnOpen

* Handle setting extra form data in Filter Bar instead of Filter Control

* Add Handle apply filter function to Apply button

* Allow applying changes instantly

* Fix types

* remove console logs

* Add Error Boundary component to Filter bar and Filter Config Modal

* fix jest tests

* update native filters tests to pass

* reset cypress baseUrl

* remove unnecessary field

* cleanup: remove unused state fields

* move unrelated types to an appropriate location

* remove misplaced resource fetch error logic

* fix cascadeParentIds error

* fix cypress password

* initial attempt at fixing scope issue

* fix bad merge

* fix lint

* trying out makeApi for saving filters

* remove unused import

* fix test

* silence bad test

* add native-filter feat flag config

* oops fix here

* remove space

* Update superset-frontend/src/common/components/index.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts

Co-authored-by: Evan Rusackas <evan@preset.io>

* use styledMount in tests

* comment

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/dashboard/components/nativeFilters/FilterBar.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/dashboard/components/nativeFilters/FilterConfigurationLink.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* address PR feedback

* fix package lock

* null guards

* Fix charts resizing

* fix cypress tests

* add in nativefilters to form data

* fix lint and test

Co-authored-by: Phillip Kelley-Dotson <pkelleydotson@yahoo.com>
Co-authored-by: Simcha Shats <simcha.shats@nielsen.com>
Co-authored-by: amitNielsen <amit.miran@nielsen.com>
Co-authored-by: Ville Brofeldt <ville.v.brofeldt@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Agata Stawarz-Pastewska <agata.stawarz-pastewska@polidea.com>
Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
2021-01-07 20:49:12 +02:00
Jesse Yang
cb4c79c499 fix: fix the flaky FilterBox test re-introduced by #12114 (#12124) 2021-01-07 20:47:56 +02:00
Yongjie Zhao
7a9488fef7 feat(explore): time picker enhancement (#11418) 2021-01-07 20:47:20 +02:00
Evan Rusackas
bc5b815976 fix(explore): overflow issue with metric options (#12129)
* fixing overflow issue

* lint!
2021-01-07 20:09:04 +02:00
Michael S. Molina
16743c3541 Improves chart save and go button (#12125)
Resolves #11748
2021-01-07 20:08:58 +02:00
Kamil Gabryjelski
04d3559d88 feat: Add Saved Metrics tab to metrics popover (#12123)
* Implement saved metrics

* Fix bug in sql editor

* Fix unit tests

* Fix outlines in popovers

* Add types for saved metrics

* Add translations

* Move savedMetricType to a separate file
2021-01-07 20:08:53 +02:00
Kamil Gabryjelski
88ae1455a4 feat(explore): metrics and filters controls redesign (#12095)
* Redesign metrics control

* Redesign filters control

* Bugfixes

* Fix unit tests

* Fix tests

* Code review fixes
2021-01-07 20:08:46 +02:00
simchaNielsen
4253edd91a feat: Support multiple queries per request (#11880)
* refactor: add queriesData fields for multiple queries

* feat: support multi queries request

* lint: fix lint

* lint: fix lint

* lint: fix lint

* fix: fix CR notes

* fix: fix CR notes

* fix: fix CR notes

* fix: fix error case for multi queries

* feat: change queryResponse to queriesResponse

* fix: revert webpack

* test: fix tests

* chore: lint

* chore: adjust asyncEvent to multiple results

* fix: lint

* fix: eslint

* fix: another eslint rule

Co-authored-by: Amit Miran <47772523+amitmiran137@users.noreply.github.com>
Co-authored-by: amitmiran137 <amit.miran@nielsen.com>
2021-01-07 20:08:39 +02:00
Kamil Gabryjelski
b744308cba fix(explore): bugs in Custom SQL editor in filter popover (#12278)
* Fix Save button disabled until clause is switched

* Fix 'undefined undefined' when sql query is empty

* fix test

Co-authored-by: Ville Brofeldt <ville.v.brofeldt@gmail.com>
(cherry picked from commit e660723a2f)
2021-01-06 12:15:11 -08:00
Kamil Gabryjelski
5f8cf8ce11 Bump superset-ui deps (#12280)
(cherry picked from commit d0668fa7e4)
2021-01-06 12:14:47 -08:00
Beto Dealmeida
08136cffc9 fix: load example data into correct DB (#12292)
* fix: load example data into correct DB

* Fix force_data

* Fix lint

(cherry picked from commit 6b2b208b3b)
2021-01-06 12:11:29 -08:00
Beto Dealmeida
e25e869b50 fix: CTAS on multiple statements (#12188)
* WIP

* Add unit tests for sql_parse

* Add unit tests for sql_lab

(cherry picked from commit 164db3e5a1)
2021-01-06 11:51:34 -08:00
Henry Yeh
d973bd8602 superset-ui: bump version to 0.16.4 (https://github.com/apache-superset/superset-ui/pull/886) 2021-01-06 11:35:15 -08:00
Daniel Vaz Gaspar
ce05a3dde3 fix: database alpha permissions (#12136)
* fix: database alpha permissions

* add test

(cherry picked from commit 2f0add3aec)
2020-12-21 15:18:19 -08:00
Beto Dealmeida
c6a08fdc46 fix: make example table name safe (#12135)
(cherry picked from commit 81f5631fe5)
2020-12-21 15:14:02 -08:00
Jesse Yang
09589059a1 fix: properly render booleans in FilterBox and explore page data preview (#12116)
(cherry picked from commit 8e625e0a64)
2020-12-21 15:13:49 -08:00
Jesse Yang
666e08b819 fix: FilterBox select lose focus when focused (#12114)
(cherry picked from commit af130ea5e9)
2020-12-21 15:13:36 -08:00
206 changed files with 8642 additions and 2335 deletions

View File

@@ -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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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"],

View File

@@ -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 });

View File

@@ -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(

View File

@@ -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');
});
});

View File

@@ -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'],
});
});

View File

@@ -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 => {

View File

@@ -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"]')

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -34,7 +34,7 @@ describe('No Results', () => {
{
expressionType: 'SIMPLE',
subject: 'state',
operator: 'in',
operator: 'IN',
comparator: ['Fake State'],
clause: 'WHERE',
sqlExpression: null,

View File

@@ -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();
});
});

View File

@@ -86,7 +86,7 @@ describe('Visualization > Area', () => {
{
expressionType: 'SIMPLE',
subject: 'region',
operator: 'in',
operator: 'IN',
comparator: ['South Asia', 'North America'],
clause: 'WHERE',
sqlExpression: null,

View File

@@ -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,

View File

@@ -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');
});

View File

@@ -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,

View File

@@ -71,7 +71,7 @@ describe('Visualization > Sunburst', () => {
{
expressionType: 'SIMPLE',
subject: 'region',
operator: 'in',
operator: 'IN',
comparator: ['South Asia', 'North America'],
clause: 'WHERE',
sqlExpression: null,

View File

@@ -19,6 +19,9 @@
"eslint-plugin-cypress": "^2.11.1"
},
"nyc": {
"reporter": ["html", "json"]
"reporter": [
"html",
"json"
]
}
}

View File

@@ -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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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,

View File

@@ -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();
});

View File

@@ -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);

View File

@@ -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');
});
});

View File

@@ -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',

View File

@@ -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,

View File

@@ -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(

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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: [],
},
},
};

View File

@@ -28,7 +28,7 @@ describe('getEffectiveExtraFilters', () => {
expect(result).toMatchObject([
{
col: 'gender',
op: 'in',
op: 'IN',
val: ['girl'],
},
{

View File

@@ -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'],
});
});

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -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') }}'",
}),
);
});

View File

@@ -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();
});
});

View File

@@ -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);
});

View File

@@ -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', () => {

View File

@@ -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);
});
});

View File

@@ -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', () => {

View File

@@ -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);
});
});
});

View File

@@ -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);

View File

@@ -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(

View File

@@ -83,7 +83,6 @@ export const controlPanelSectionsChartOptionsTable = [
name: 'all_columns',
config: {
type: 'SelectControl',
queryField: 'columns',
multi: true,
label: t('Columns'),
default: [],

View File

@@ -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}')`,
);
});
});
});

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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);
});
});
});

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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 (

View File

@@ -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}
/>

View File

@@ -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));
});
});

View File

@@ -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,
};
},

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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' }}

View 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]);
}

View 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;
}

View File

@@ -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"
/>
);
}

View File

@@ -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,

View File

@@ -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;
`;

View 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
/>
);
}

View File

@@ -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';

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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}

View File

@@ -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,
),
);

View File

@@ -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 && (

View File

@@ -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,

View File

@@ -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}

View File

@@ -0,0 +1,85 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
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>
);
};

View File

@@ -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}

View File

@@ -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;

View File

@@ -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
/>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
});
}

View File

@@ -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;
};
};

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View 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;
}
}

View File

@@ -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