Compare commits

...

90 Commits

Author SHA1 Message Date
Elizabeth Thompson
d29f661416 update changelog 2022-01-12 16:56:02 -08:00
Stephen Liu
fbc81d7883 fix(dashboard): scope status of filter not update in dashboard metadata (#17945)
* fix(dashboard): scope status of filter not update in dashboard metadata

* fix save
2022-01-12 16:33:54 -08:00
Elizabeth Thompson
caf0cff6de update changelog 2022-01-12 12:52:39 -08:00
Daniel Vaz Gaspar
6b868b0f44 chore: bump FAB to 3.4.3 (#17964) 2022-01-12 11:52:45 -08:00
Marco Porracin
24f4f67dde bump gunicorn 20.1.0 (#17894)
(cherry picked from commit 6e59a515b8)
2022-01-12 11:47:45 -08:00
Geido
a222ba8e06 Check validity of control item (#17349)
(cherry picked from commit d0085b1b29)
2022-01-12 11:47:44 -08:00
Stephen Liu
75d8006e2a fix(dashboard): update native filter info in metadata is not updated (#17842)
(cherry picked from commit ec48dd5c40)
2022-01-06 17:55:41 -08:00
kamalkeshavani-aiinside
20eaeae68f chore: Bump FAB to 3.4.0 (#17420)
Bumping FAB to latest 3.4.0

(cherry picked from commit 02a9b84b14)
2022-01-05 09:52:10 -08:00
Elizabeth Thompson
5045ff4d91 chore: add release to pip requirements (#17752)
* add requests to pip requirements

* fix tests

* add redis typing for mypy

* fix test
2021-12-21 17:46:33 -08:00
Elizabeth Thompson
1e2b2f10f0 update changelog 2021-12-21 17:19:02 -08:00
Elizabeth Thompson
96c18b4272 fix test lint (#17835) 2021-12-21 17:17:13 -08:00
Geido
8057582b02 feat: Certify Charts and Dashboards (#17335)
* Certify charts

* Format

* Certify dashboards

* Format

* Refactor card certification

* Clear details when certified by empty

* Show certification in detail page

* Add RTL tests

* Test charts api

* Enhance integration tests

* Lint

* Fix dashboards count

* Format

* Handle empty value

* Handle empty slice

* Downgrade migration

* Indent

* Use alter

* Fix revision

* Fix revision
2021-12-21 11:51:55 -08:00
serenajiang
70228ad3fb fix(dashboard): commit update once (#17781)
(cherry picked from commit 3657cbea7f)
2021-12-20 13:38:55 -08:00
Geido
f4661d6924 fix: Remove positions from json_metadata (#17766)
* Remove positions from json_metadata

* Update superset-frontend/src/dashboard/components/PropertiesModal/index.tsx

Co-authored-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>

* Indent

Co-authored-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
(cherry picked from commit 274fb37a91)
2021-12-20 13:38:55 -08:00
John Bodley
398a01f7dc chore(engine): Translate fractional time grains—requires @superset-ui bump (#17078)
* chore(engine): Translate fractional time grains

* Bump @superset-ui

Co-authored-by: John Bodley <john.bodley@airbnb.com>
2021-12-15 17:01:09 -08:00
Elizabeth Thompson
752062674b Revert "fix: import should accept old keys (#17330)"
This reverts commit 0b8507fe77.
2021-12-15 09:30:26 -08:00
Daniel Vaz Gaspar
cda62508c4 ci: temp fix for mysqlclient on an OS regression bug (#17724) 2021-12-15 09:30:20 -08:00
Elizabeth Thompson
2e0fe93269 fix prettier/lint error 2021-12-10 17:08:04 -08:00
Elizabeth Thompson
4991272b9c update changelog 2021-12-10 16:24:17 -08:00
Geido
ba6d5b9754 fix: Save properties after applying changes in Dashboard (#17570)
* Refactor PropertiesModal

* Update json_metadata fully

* Clean up

* Verify values

* Catch changed to metadata

* Always updated dashboard info on update

* Avoid unnecessary fetches

* Formt

* Fix copy dashboards

* Fixes onUpdate onCopy handlers

* Pylint

* Update tests

* Clean up

* Handle data on show

* Change Save to Apply

* Update Cypress save test

* Update Cypress edit prop test

* Update PropertiesModal test

* Fix duplicate request with cross filters

* Improve code style

* Fix typo

* Lint
2021-12-10 16:12:49 -08:00
Ville Brofeldt
8b0ab83119 chore(sql): clean up invalid filter clause exception types (#17702)
* chore(sql): clean up invalid filter clause exception types

* fix lint

* rename exception
2021-12-10 16:07:03 -08:00
Geido
4c00bd4332 fix(Dashboard): Copy dashboard with duplicating charts 500 error (#17707)
* Fix copy dashboard with charts

* Fix Cypress test
2021-12-10 16:01:05 -08:00
Beto Dealmeida
77c4f2cb11 fix: set correct schema on config import (#16041)
* fix: set correct schema on config import

* Fix lint

* Fix test

* Fix tests

* Fix another test

* Fix another test

* Fix base test

* Add helper function

* Fix examples

* Fix test

* Fix test

* Fixing more tests

(cherry picked from commit 1fbce88a46)
2021-12-10 15:40:06 -08:00
Elizabeth Thompson
3d8ce130ba update changelog 2021-12-08 14:59:13 -08:00
Craig Rueda
9837feff19 chore(datasets): Sanitizing /save response (#17579)
(cherry picked from commit ac76defc05)
2021-12-08 13:51:15 -08:00
Corbin Robb
9468bdf0c1 fix(sqllab): Have table name tooltip only show when name is truncated (#17386)
* Add conditional to table name tooltip to only show when overflowing

* Remove uneccessary state and useEffect, a little clean up and slight refactoring

Co-authored-by: Corbin Robb <corbin@Corbins-MacBook-Pro.local>
(cherry picked from commit 8e1619b105)
2021-12-06 13:46:14 -08:00
Elizabeth Thompson
c42ff7972f use full resultType with csv download on chart in dashboard (#17431)
(cherry picked from commit 71e3fa1bf3)
2021-12-06 13:46:14 -08:00
Ville Brofeldt
46343a9dca fix: avoid escaping bind-like params containing colons (#17419)
* fix: avoid escaping bind-like params containing colons

* fix query for mysql

* address comments

(cherry picked from commit ad8a7c42f9)
2021-12-06 13:46:14 -08:00
Erik Ritter
9818bc5e7a Revert "fix(native-filters): Fix update ownState (#17181)" (#17311)
This reverts commit cf284ba3c7.

(cherry picked from commit 7c6d6f47bf)
2021-12-06 13:46:14 -08:00
Geido
733fc7494c Handle undefined (#17183)
(cherry picked from commit 91199c30d8)
2021-12-06 13:46:13 -08:00
Elizabeth Thompson
361e510cae update changelog and updating.md for 1.4.0 2021-11-22 17:27:19 -08:00
Elizabeth Thompson
eb64f82c20 fix conflicts 2021-11-22 16:48:37 -08:00
Grace Guo
3aa554b422 fix: sql lab crash caused by invalid template (#17133)
(cherry picked from commit 96f4421961)
2021-11-22 16:11:45 -08:00
David Aaron Suddjian
ed45717d5f fix(explore): remove unnecessary parameters from the explore url (#17123)
* remove unnecessary parameters from the explore url

* refactor, test

(cherry picked from commit 57f869cf22)
2021-11-22 16:11:45 -08:00
wijnanjo
6f932f9ec1 fix for undefined userId (#17117)
Co-authored-by: jo.wijnant <jo.wijnant@kontron.com>
(cherry picked from commit c9c669d179)
2021-11-22 16:11:45 -08:00
Lyndsi Kay Williams
b577773e30 fix(sqllab): Hover tooltip flashes in SQL Lab (#17068)
* Changed SQL Lab result column header-style width to max-content

* Changed .ant-tooltip-open to block and tooltip placement to topLeft

* Moved tooltip style changes to local implementation instead of global

(cherry picked from commit 635898a76d)
2021-11-22 16:11:45 -08:00
Erik Ritter
dfd0ed395c fix: prevent caching error pages (#17100)
(cherry picked from commit 031f594fa3)
2021-11-22 16:11:45 -08:00
Beto Dealmeida
cca78f1b6e fix: accept headers on import (#17080)
* fix: accept headers on import

* Add unit test

(cherry picked from commit 40e9add641)
2021-11-22 16:11:44 -08:00
jinghua-qa
b4f4f4cdfa fix(other): column name in created content on profile page (#17029)
* fix: fix name in created content on profile page

* add more fix on mutater

(cherry picked from commit f2d41dc416)
2021-11-22 16:11:44 -08:00
Geido
5899ae163a fix: Exclude SUPERSET_DEFAULT from the list of available color schemes (#17018)
* Handle SUPERSET_DEFAULT theme

* Update superset-frontend/src/explore/components/controls/ColorSchemeControl.jsx

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

* Update superset-frontend/src/explore/components/controls/ColorSchemeControl.jsx

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
(cherry picked from commit 14b7f6cdba)
2021-11-22 16:11:44 -08:00
Phillip Kelley-Dotson
9459d09ae7 initial fix (#16998)
(cherry picked from commit 2c8e06e929)
2021-11-22 16:11:44 -08:00
Beto Dealmeida
0b8507fe77 fix: import should accept old keys (#17330)
* fix: import should accept old keys

* Fix lint

* Preserve V1 schema
2021-11-22 11:58:13 -08:00
Beto Dealmeida
c015e661a9 fix: clear 'delete' confirmation (#17345)
* fix: clear 'delete' confirmation

* Add tests

(cherry picked from commit 43f4ab845a)
2021-11-22 11:56:56 -08:00
Elizabeth Thompson
7d9f63eda7 fix: add fallback and validation for report and cron timezones (#17338)
* add fallback and validation for report and cron timezones

* add logging to exception catch

* Run black

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
(cherry picked from commit f10bc6d8fe)
2021-11-22 11:53:45 -08:00
Hugh A. Miles II
30c6ec07ef fix: Allow users to update database in Dataset Edit Modal (#17265)
* add condition to fix save

* remove console.log

(cherry picked from commit d0bad96b1a)
2021-11-22 11:53:45 -08:00
Elizabeth Thompson
680cdca14a fix: update values for default timezone selector (#17124)
* update values for default timezone selector

* fix casing and comment

* Update TimezoneSelector.test.tsx

(cherry picked from commit ae4ced8da6)
2021-11-22 11:53:45 -08:00
AAfghahi
8a24374f9f fix(AlertReportModal): Text Area Change (#17176)
* Text Area Change

* added unique key to component

* tests passing

* changed css

* data flow and naming convention

(cherry picked from commit 5948a9fd02)
2021-11-22 11:31:20 -08:00
Kamil Gabryjelski
33826cd50f fix(explore): Metrics disappearing after removing metric from dataset (#17201)
* fix(explore): Metrics disappearing after removing metric from dataset

* fix test

* Apply fix to non-dnd controls

* Make adhoc metrics pick up changes from dataset columns

* Remove console log

* Fix bug in nondnd controls

(cherry picked from commit fa44325a36)
2021-11-22 11:31:20 -08:00
Ville Brofeldt
c4b57e6b3e ci: fix broken test skips (#17005)
(cherry picked from commit 9e980b6f2b)
2021-11-22 11:31:20 -08:00
Michael S. Molina
7db1caa2ad fix: Unnecessary queries when changing filter values (#16994)
(cherry picked from commit c471a85170)
2021-11-22 11:31:20 -08:00
jinghua-qa
687676cb09 fix: letter format of sort chart in dashboard edit (#17003)
(cherry picked from commit 6dc00b3e3f)
2021-11-22 11:31:20 -08:00
serenajiang
2eaf5c854e fix(sqllab): SqlJsonExecutionContext.query null pointer (#16997)
(cherry picked from commit cde4cdcd0c)
2021-11-22 11:31:20 -08:00
Lyndsi Kay Williams
09b853c831 fix: FilterableTable result div width (#16912)
* FilterableTable result div width is now inherit

* Changed style to css

(cherry picked from commit 90cfa7fb22)
2021-11-22 11:31:20 -08:00
Erik Ritter
5f34ae2cf3 fix: Use production build config for cypress tests (#16978)
* fix: Use production build config for cypress tests

* set usedExports to global

(cherry picked from commit 2757b93fea)
2021-11-22 11:31:20 -08:00
Geido
ea3d51efec fix: Color consistency (#17089)
* Update label colors on the fly

* Clean up

* Improve getFormDataWithExtraFilters

* Improve code structure

* Remove labelColors from formData

* Exclude label_colors from URL

* Refactor color scheme implementation

* Clean up

* Refactor and simplify

* Fix lint

* Remove unnecessary ColorMapControl

* Lint

* Give json color scheme precedence

* Add label_colors prop in metadata

* Separate owners and dashboard meta requests

* Remove label_colors control

* bump superset-ui 0.18.19

* Fix end of file

* Update tests

* Fix lint

* Update Cypress

* Update setColorScheme method

* Use Antd modal body
2021-11-22 10:55:21 -08:00
Phillip Kelley-Dotson
55b3da661e fix: show onhover menu only in edit mode (#17034)
* fix on markdown hover menu

* lint-fix
2021-11-22 10:49:04 -08:00
Geido
b775193f9f fix: Verify when null value should be undefined in Select (#17013)
* Check for null value

* Safety chek SelectControl and SelectAsyncControl
2021-11-22 10:46:23 -08:00
Geido
7c966c52dc chore: Select component refactoring - SelectAsyncControl - Iteration 5 (#16609)
* Refactor Select

* Implement onError

* Separate async request

* Lint

* Allow clear

* Improve type

* Reconcile with Select latest changes

* Fix handleOnChange

* Clean up

* Add placeholder

* Accept null values

* Update superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx

Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>

* Clean up

* Implement type guard

* Fix lint

* Catch error within loadOptions

Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>
2021-11-22 10:46:07 -08:00
CodeingBoy
6da69a821f fix(sqllab): Bugfix for tracking url transformation (#17263)
* Bugfix for tracking url transformation

* Fix linting

(cherry picked from commit 2544a4a8ac)
2021-11-17 13:09:20 -08:00
Christian Pfarr
03481fe28d feat: Drill ODBC/JDBC Impersonation feature (#17353)
* Added Drill ODBC Impersonation feature and necessary translations/docs

* Code Cleanup

* add jdbc impersonation_target parameter

* add unittests for DrillEngineSpec.modify_url_for_impersonation method

* reformat test_drill.py with black formatter

* run pre-commit locally

Co-authored-by: Christian Pfarr <Christian.Pfarr@deutschebahn.com>
Co-authored-by: Christian Pfarr <z0ltrix+gitlab@pm.me>
(cherry picked from commit 333b1371f7)
2021-11-17 13:09:19 -08:00
Étienne Boisseau-Sierra
f467a7ca57 fix(cli): fail CLI script on failed import/export (#16976)
* Test that failing export or import is done properly

For each CLI entry-point we will modify, we make sure that:

- a failing process exits with a non-0 exit code,
- an error is logged.

Signed-off-by: Étienne Boisseau-Sierra <etienne.boisseau-sierra@unipart.io>

* Exit process with error if export/import failed

Bubble exception up when failing import or export

During a CLI import or export of dashboards, if the process fails, the
exception it caught and a simple message is sent to the logger.
This makes that from a shell point of view, the script was successfull —
cf. #16956.

To prevent this, we want to ensure that the process exits with an error
(i.e., a non-0 exit-code) should the export or import fail mid-flight.

Signed-off-by: Étienne Boisseau-Sierra <etienne.boisseau-sierra@unipart.io>
(cherry picked from commit f0c0ef7048)
2021-11-17 13:09:18 -08:00
simcha90
35d8a40634 fix(native-filters): Fix update ownState (#17181)
* fix:fix get permission function

* fix: fix own state update

* refactor: fix CR notes

(cherry picked from commit cf284ba3c7)
2021-11-17 13:09:18 -08:00
Geido
5d43a5926c chore(Dashboard): Disable save button in Native Filters when an error is present (#17037)
* Disable save on error

* Remove removed erroredFilter

* Fix cdisabled check
2021-10-27 14:09:07 -07:00
Geido
52ac5ecdbb chore(Dashboard): Highlight errored filters on the left pane of the Native Filters form plus several enhancements (#16940)
* Implement errored filters

* Clean up

* Handle errors on the fly

* Implement handleErroredFilters

* Reset errors

(cherry picked from commit a6173f1929)
2021-10-27 14:07:23 -07:00
Erik Ritter
e80c8ea980 fix: error alert levels again (#17027) 2021-10-27 13:57:42 -07:00
Erik Ritter
43019a1b9d fix: error alerts again (#17026) 2021-10-27 13:50:41 -07:00
Erik Ritter
3f869aded2 fix: error alerts js crash (#17015) 2021-10-27 13:49:30 -07:00
Michael S. Molina
f3bde45e5c fix: Filtering db names while creating dataset is not working (#17023) 2021-10-27 13:46:30 -07:00
Hugh A. Miles II
cf4e129f5d use typing_extension instead (#17174)
(cherry picked from commit aa0f4d6c4f)
2021-10-27 13:38:55 -07:00
Geido
23313fdb1f fix(Explore): Undefined owners (#17167)
* Reconcile owners data

* Fix on save

(cherry picked from commit f580f6bcba)
2021-10-27 13:38:55 -07:00
Ville Brofeldt
d90bfa65ef fix(filter-indicator): show filters handled by jinja as applied (#17140)
(cherry picked from commit d7834f17e3)
2021-10-27 13:38:54 -07:00
simcha90
4e3fa1a141 feat: Custom filters control (#17006)
* fix:fix get permission function

* feat: customize filter control

* fix: fix types

* fix: fix merge

* refactor: update according CR

* refactor: fix CR notes

(cherry picked from commit eebc953dd5)
2021-10-27 13:38:54 -07:00
Ville Brofeldt
35dda573cf fix: escape bind-like strings in virtual table query (#17111)
(cherry picked from commit 434b5767c9)
2021-10-27 13:38:54 -07:00
Daniel Vaz Gaspar
5bfa6e96cd fix: Bump FAB to 3.3.4 (#17113)
(cherry picked from commit d944503873)
2021-10-27 13:38:54 -07:00
Kamil Gabryjelski
81da0fb466 fix(dashboard): race condition between hydrating dashboard and set active tabs (#17084)
* fix(dashboard): race condition between hydrating dashboard and set active tabs

* Fix linting

* Change variable name

(cherry picked from commit 3ad7483dc1)
2021-10-27 13:38:54 -07:00
Hugh A. Miles II
c03771dc6d fix: Owners selection in dataset edit UX (#17063)
* boilerplate

* update owner select component

* this is working

* update onchange

* refactorig

* you need to useMemo or things break

* update test

* prettier

* move logic into bootstrap data endpoint

* address concerns

* oops

* oops

* fix test

(cherry picked from commit 959fd763a8)
2021-10-27 13:38:54 -07:00
Elizabeth Thompson
ed98027adc add logging on successful data uploads (#17065)
(cherry picked from commit c2e1ab6550)
2021-10-27 13:38:54 -07:00
Beto Dealmeida
43e27b1137 fix: clear modal state after adding dataset (#17044)
* fix: clear modal state after adding dataset

* Fix test

* Small fixes

(cherry picked from commit 16a1df75fc)
2021-10-27 13:38:53 -07:00
Michael S. Molina
bd5d787257 fix: Loading indicator of table and schema selectors (#17040)
(cherry picked from commit 7c1c89c94b)
2021-10-27 13:38:53 -07:00
AAfghahi
f56a3d8b1a bug fix (#17019)
(cherry picked from commit e32a12fa0b)
2021-10-27 13:38:53 -07:00
Kamil Gabryjelski
3d77daaf68 fix(dashboard): Race condition when setting activeTabs with nested tabs (#17007)
* fix(dashboard): Race condition when setting activeTabs with nested tabs

* Remove activeTabs prop from Tabs

(cherry picked from commit 45908ff104)
2021-10-27 13:38:52 -07:00
Yongjie Zhao
19d2fef490 fix: rolling and cum operator on multiple series (#16945)
* fix: rolling and cum operator on multiple series

* add UT

* updates

(cherry picked from commit fd8461406d)
2021-10-27 13:38:52 -07:00
Hugh A. Miles II
4d33f7b7b6 fix: check if owners are actually being updated in PUT /datasets/<id> (#16941)
* check if owners are actually being updated

* move logic to FE

* fix

* fix

(cherry picked from commit 40861b3db3)
2021-10-27 13:38:52 -07:00
Beto Dealmeida
ddb35a3633 fix(BigQuery): explicitly quote columns in select_star (#16822)
* fix (BigQuery): explicitly quote columns in select_star

* Fix test

* Fix SELECT * in BQ

* Add unit tests

* Remove type changes

(cherry picked from commit c993c5845f)
2021-10-27 13:38:52 -07:00
Michael S. Molina
8755911765 chore: Translates the favorite filter param (#16990)
(cherry picked from commit 191033cb44)
2021-10-27 13:38:52 -07:00
Michael S. Molina
012f6ac206 fix: When click on "View all" from favorite tab, get error (#16988)
(cherry picked from commit c57719128f)
2021-10-27 13:38:51 -07:00
Youkyoung Cha
b2b3cd73fd fix: handle mixed time-series error (#16928)
* rebase to master

* Fix line 402

Co-authored-by: yougyoung <yougyoung@pubg.com>
(cherry picked from commit 93ebe3d963)
2021-10-27 13:38:51 -07:00
Ville Brofeldt
6ed84f2d35 feat: upgrade docker image to py38 and add support for py39 (#16889)
* feat: upgrade docker image to py38 and add support for py39

* update required tests

(cherry picked from commit 82601abe17)
2021-10-27 13:38:51 -07:00
Elizabeth Thompson
a6a3cedf6e fix: Revert "fix: RBAC hide right menu (#16902)" (#16968)
* Revert "fix: RBAC hide right menu (#16902)"

This reverts commit 87baac7650.

* fix failing test

(cherry picked from commit 5866d5ebb0)
2021-10-27 13:38:51 -07:00
Grace Guo
083ff12864 chore:upgrade superset-ui dependencies (#16965)
(cherry picked from commit 85e3cec521)
2021-10-27 13:38:51 -07:00
285 changed files with 9265 additions and 22797 deletions

View File

@@ -67,7 +67,7 @@ jobs:
if: steps.check.outcome == 'failure'
uses: actions/setup-python@v2
with:
python-version: "3.7"
python-version: "3.8"
- name: OS dependencies
if: steps.check.outcome == 'failure'
uses: ./.github/actions/cached-dependencies

View File

@@ -28,6 +28,7 @@ jobs:
continue-on-error: true
run: ./scripts/ci_check_no_file_changes.sh frontend
- name: Setup Node.js
if: steps.check.outcome == 'failure'
uses: actions/setup-node@v2
with:
node-version: '16'

View File

@@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-python@v2
with:
python-version: 3.7
python-version: 3.8
- name: Set up chart-testing
uses: ./.github/actions/chart-testing-action

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.7]
python-version: [3.8]
env:
PYTHONPATH: ${{ github.workspace }}
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -77,7 +77,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.7, 3.8]
python-version: [3.8, 3.9]
env:
PYTHONPATH: ${{ github.workspace }}
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
@@ -141,7 +141,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.7]
python-version: [3.8]
env:
PYTHONPATH: ${{ github.workspace }}
SUPERSET_CONFIG: tests.integration_tests.superset_test_config

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.7]
python-version: [3.8]
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v2
@@ -29,6 +29,7 @@ jobs:
continue-on-error: true
run: ./scripts/ci_check_no_file_changes.sh python
- name: Setup Python
if: steps.check.outcome == 'failure'
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
@@ -50,7 +51,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.7]
python-version: [3.8]
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v2
@@ -76,7 +77,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.7]
python-version: [3.8]
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v2

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.7,3.8]
python-version: [3.8, 3.9]
env:
PYTHONPATH: ${{ github.workspace }}
steps:

View File

@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
python-version: [3.7]
python-version: [3.8]
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v2

View File

@@ -27,7 +27,7 @@ repos:
rev: v0.910
hooks:
- id: mypy
additional_dependencies: [types-all]
additional_dependencies: [types-all, types-redis]
- repo: https://github.com/peterdemin/pip-compile-multi
rev: v2.4.1
hooks:

File diff suppressed because it is too large Load Diff

View File

@@ -420,7 +420,7 @@ For example, the image referenced above actually lives in `superset-frontend/src
#### OS Dependencies
Make sure your machine meets the [OS dependencies](https://superset.apache.org/docs/installation/installing-superset-from-scratch#os-dependencies) before following these steps.
Make sure your machine meets the [OS dependencies](https://superset.apache.org/docs/installation/installing-superset-from-scratch#os-dependencies) before following these steps.
You also need to install MySQL or [MariaDB](https://mariadb.com/downloads).
Ensure that you are using Python version 3.7 or 3.8, then proceed with:
@@ -523,6 +523,7 @@ Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in or
##### nvm and node
First, be sure you are using the following versions of Node.js and npm:
- `Node.js`: Version 16
- `npm`: Version 7
@@ -752,15 +753,21 @@ Note that the test environment uses a temporary directory for defining the
SQLite databases which will be cleared each time before the group of test
commands are invoked.
There is also a utility script included in the Superset codebase to run python tests. The [readme can be
There is also a utility script included in the Superset codebase to run python integration tests. The [readme can be
found here](https://github.com/apache/superset/tree/master/scripts/tests)
To run all tests for example, run this script from the root directory:
To run all integration tests for example, run this script from the root directory:
```bash
scripts/tests/run.sh
```
You can run unit tests found in './tests/unit_tests' for example with pytest. It is a simple way to run an isolated test that doesn't need any database setup
```bash
pytest ./link_to_test.py
```
### Frontend Testing
We use [Jest](https://jestjs.io/) and [Enzyme](https://airbnb.io/enzyme/) to test TypeScript/JavaScript. Tests can be run with:

View File

@@ -18,7 +18,7 @@
######################################################################
# PY stage that simply does a pip install on our requirements
######################################################################
ARG PY_VER=3.7.9
ARG PY_VER=3.8.12
FROM python:${PY_VER} AS superset-py
RUN mkdir /app \
@@ -73,7 +73,7 @@ RUN cd /app/superset-frontend \
######################################################################
# Final lean image...
######################################################################
ARG PY_VER=3.7.9
ARG PY_VER=3.8.12
FROM python:${PY_VER} AS lean
ENV LANG=C.UTF-8 \
@@ -94,7 +94,7 @@ RUN mkdir -p ${PYTHONPATH} \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY --from=superset-py /usr/local/lib/python3.7/site-packages/ /usr/local/lib/python3.7/site-packages/
COPY --from=superset-py /usr/local/lib/python3.8/site-packages/ /usr/local/lib/python3.8/site-packages/
# Copying site-packages doesn't move the CLIs, so let's copy them one by one
COPY --from=superset-py /usr/local/bin/gunicorn /usr/local/bin/celery /usr/local/bin/flask /usr/bin/
COPY --from=superset-node /app/superset/static/assets /app/superset/static/assets

View File

@@ -15,8 +15,8 @@
# limitations under the License.
#
# Python version installed; we need 3.8 or 3.7
PYTHON=`command -v python3.8 || command -v python3.7`
# Python version installed; we need 3.7-3.9
PYTHON=`command -v python3.9 || command -v python3.8 || command -v python3.7`
.PHONY: install superset venv pre-commit
@@ -62,7 +62,7 @@ update-js:
venv:
# Create a virtual environment and activate it (recommended)
if ! [ -x "${PYTHON}" ]; then echo "You need Python 3.7 or 3.8 installed"; exit 1; fi
if ! [ -x "${PYTHON}" ]; then echo "You need Python 3.7, 3.8 or 3.9 installed"; exit 1; fi
test -d venv || ${PYTHON} -m venv venv # setup a python3 virtualenv
. venv/bin/activate

View File

@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
FROM python:3.7-buster
FROM python:3.8-buster
RUN useradd --user-group --create-home --no-log-init --shell /bin/bash superset

View File

@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
FROM python:3.7-buster
FROM python:3.8-buster
RUN useradd --user-group --create-home --no-log-init --shell /bin/bash superset

View File

@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
FROM python:3.7-buster
FROM python:3.8-buster
ARG VERSION
RUN git clone --depth 1 --branch ${VERSION} https://github.com/apache/superset.git /superset

View File

@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
FROM python:3.7-buster
FROM python:3.8-buster
RUN apt-get update -y
RUN apt-get install -y jq

View File

@@ -26,8 +26,18 @@ assists people when migrating to a new version.
### Breaking Changes
- [16660](https://github.com/apache/incubator-superset/pull/16660): The `columns` Jinja parameter has been renamed `table_columns` to make the `columns` query object parameter available in the Jinja context.
- [16711](https://github.com/apache/incubator-superset/pull/16711): The `url_param` Jinja function will now by default escape the result. For instance, the value `O'Brien` will now be changed to `O''Brien`. To disable this behavior, call `url_param` with `escape_result` set to `False`: `url_param("my_key", "my default", escape_result=False)`.
### Potential Downtime
### Deprecations
### Other
## 1.4.0
### Breaking Changes
- [16660](https://github.com/apache/superset/pull/16660): The `columns` Jinja parameter has been renamed `table_columns` to make the `columns` query object parameter available in the Jinja context.
- [16711](https://github.com/apache/superset/pull/16711): The `url_param` Jinja function will now by default escape the result. For instance, the value `O'Brien` will now be changed to `O''Brien`. To disable this behavior, call `url_param` with `escape_result` set to `False`: `url_param("my_key", "my default", escape_result=False)`.
### Potential Downtime
@@ -35,13 +45,13 @@ assists people when migrating to a new version.
### Other
- [16809](https://github.com/apache/incubator-superset/pull/16809): When building the superset frontend assets manually, you should now use Node 16 (previously Node 14 was required/recommended). Node 14 will most likely still work for at least some time, but is no longer actively tested for on CI.
- [16809](https://github.com/apache/superset/pull/16809): When building the superset frontend assets manually, you should now use Node 16 (previously Node 14 was required/recommended). Node 14 will most likely still work for at least some time, but is no longer actively tested for on CI.
## 1.3.0
### Breaking Changes
- [15909](https://github.com/apache/incubator-superset/pull/15909): a change which
- [15909](https://github.com/apache/superset/pull/15909): a change which
drops a uniqueness criterion (which may or may not have existed) to the tables table. This constraint was obsolete as it is handled by the ORM due to differences in how MySQL, PostgreSQL, etc. handle uniqueness for NULL values.
### Potential Downtime

View File

@@ -65,7 +65,7 @@ We don't recommend using the system installed Python. Instead, first install the
brew install readline pkg-config libffi openssl mysql postgres
```
You should install a recent version of Python (Superset uses 3.7.9). We'd recommend using a Python version manager like [pyenv](https://github.com/pyenv/pyenv) (and also [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv)).
You should install a recent version of Python (the official docker image uses 3.8.12). We'd recommend using a Python version manager like [pyenv](https://github.com/pyenv/pyenv) (and also [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv)).
Let's also make sure we have the latest version of `pip` and `setuptools`:

View File

@@ -730,13 +730,13 @@
"PT5M",
"PT10M",
"PT15M",
"PT0.5H",
"PT30M",
"PT1H",
"PT6H",
"P1D",
"P1W",
"P1M",
"P0.25Y",
"P3M",
"P1Y",
"1969-12-28T00:00:00Z/P1W",
"1969-12-29T00:00:00Z/P1W",
@@ -998,13 +998,13 @@
"PT5M",
"PT10M",
"PT15M",
"PT0.5H",
"PT30M",
"PT1H",
"PT6H",
"P1D",
"P1W",
"P1M",
"P0.25Y",
"P3M",
"P1Y",
"1969-12-28T00:00:00Z/P1W",
"1969-12-29T00:00:00Z/P1W",
@@ -2722,7 +2722,7 @@
"type": "string"
},
"impersonate_user": {
"description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
"description": "If Presto, Trino or Drill all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
"type": "boolean"
},
"parameters": {
@@ -2816,7 +2816,7 @@
"type": "string"
},
"impersonate_user": {
"description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
"description": "If Presto, Trino or Drill all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
"type": "boolean"
},
"parameters": {
@@ -2866,7 +2866,7 @@
"type": "string"
},
"impersonate_user": {
"description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
"description": "If Presto, Trino or Drill all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
"type": "boolean"
},
"parameters": {
@@ -2914,7 +2914,7 @@
"type": "string"
},
"impersonate_user": {
"description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
"description": "If Presto, Trino or Drill all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
"type": "boolean"
},
"parameters": {

View File

@@ -19,3 +19,7 @@
pyrsistent>=0.16.1,<0.17
zipp==3.4.1
sasl==0.3.1
packaging==21.0
wrapt==1.12.1
certifi==2021.5.30
charset-normalizer==2.0.4

View File

@@ -1,4 +1,4 @@
# SHA1:04efc15075d69b1a2b5fa6c76b84c77a2f5c04e3
# SHA1:fe363b0ea02d7589c2ba5a1cf936247a966a6d5e
#
# This file is autogenerated by pip-compile-multi
# To update, run:
@@ -35,10 +35,18 @@ cachelib==0.1.1
# via apache-superset
celery==4.4.7
# via apache-superset
certifi==2021.5.30
# via
# -r requirements/base.in
# requests
cffi==1.14.6
# via cryptography
chardet==4.0.0
# via aiohttp
charset-normalizer==2.0.4
# via
# -r requirements/base.in
# requests
click==7.1.2
# via
# apache-superset
@@ -77,7 +85,7 @@ flask==1.1.4
# flask-openid
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==3.3.3
flask-appbuilder==3.4.3
# via apache-superset
flask-babel==1.0.0
# via flask-appbuilder
@@ -109,7 +117,7 @@ geopy==2.2.0
# via apache-superset
graphlib-backport==1.0.3
# via apache-superset
gunicorn==20.0.4
gunicorn==20.1.0
# via apache-superset
holidays==0.10.3
# via apache-superset
@@ -118,6 +126,7 @@ humanize==3.11.0
idna==3.2
# via
# email-validator
# requests
# yarl
isodate==0.6.0
# via apache-superset
@@ -166,6 +175,7 @@ numpy==1.21.1
# pyarrow
packaging==21.0
# via
# -r requirements/base.in
# bleach
# deprecation
pandas==1.2.5
@@ -226,6 +236,8 @@ pyyaml==5.4.1
# apispec
redis==3.5.3
# via apache-superset
requests==2.26.0
# via apache-superset
sasl==0.3.1
# via -r requirements/base.in
selenium==3.141.0
@@ -257,7 +269,7 @@ sqlalchemy==1.3.24
# flask-sqlalchemy
# marshmallow-sqlalchemy
# sqlalchemy-utils
sqlalchemy-utils==0.36.8
sqlalchemy-utils==0.37.8
# via
# apache-superset
# flask-appbuilder
@@ -270,7 +282,9 @@ typing-extensions==3.10.0.0
# aiohttp
# apache-superset
urllib3==1.26.6
# via selenium
# via
# requests
# selenium
vine==1.3.0
# via
# amqp
@@ -281,6 +295,8 @@ werkzeug==1.0.1
# via
# flask
# flask-jwt-extended
wrapt==1.12.1
# via -r requirements/base.in
wtforms==2.3.3
# via
# flask-wtf

View File

@@ -18,7 +18,7 @@
-r base.in
flask-cors>=2.0.0
mysqlclient==1.4.2.post1
pillow>=7.0.0,<8.0.0
pillow>=8.3.1,<9
pydruid>=0.6.1,<0.7
pyhive[hive]>=0.6.1
psycopg2-binary==2.8.5

View File

@@ -1,4 +1,4 @@
# SHA1:e4f3ea65026a8aec3735d6d9977f89fef4a1a4f9
# SHA1:dbd3e93a11a36fc6b18d6194ac96ba29bd0ad2a8
#
# This file is autogenerated by pip-compile-multi
# To update, run:
@@ -16,10 +16,6 @@ botocore==1.21.19
# s3transfer
cached-property==1.5.2
# via tableschema
certifi==2021.5.30
# via requests
charset-normalizer==2.0.4
# via requests
et-xmlfile==1.1.0
# via openpyxl
flask-cors==3.0.10
@@ -40,7 +36,7 @@ mysqlclient==1.4.2.post1
# via -r requirements/development.in
openpyxl==3.0.7
# via tabulator
pillow==7.2.0
pillow==8.3.1
# via -r requirements/development.in
progress==1.6
# via -r requirements/development.in
@@ -54,11 +50,6 @@ pyhive[hive]==0.6.4
# via -r requirements/development.in
pyinstrument==4.0.2
# via -r requirements/development.in
requests==2.26.0
# via
# pydruid
# tableschema
# tabulator
rfc3986==1.5.0
# via tableschema
s3transfer==0.5.0

View File

@@ -20,3 +20,4 @@ tox
py>=1.10.0
click==7.1.2
packaging==21.0
pyparsing==2.4.7

View File

@@ -1,4 +1,4 @@
# SHA1:17ab2346746deadfc557e1df96014e77c8337f4b
# SHA1:32bae3a7c758a411c20c86ff4d5bff825be46314
#
# This file is autogenerated by pip-compile-multi
# To update, run:
@@ -45,7 +45,9 @@ py==1.10.0
# -r requirements/integration.in
# tox
pyparsing==2.4.7
# via packaging
# via
# -r requirements/integration.in
# packaging
pyyaml==5.4.1
# via pre-commit
six==1.16.0

View File

@@ -92,8 +92,6 @@ wcwidth==0.2.5
# via prompt-toolkit
websocket-client==1.2.0
# via docker
wrapt==1.12.1
# via astroid
# The following packages are considered to be unsafe in a requirements file:
# pip

View File

@@ -18,6 +18,11 @@
#
set -e
# Temporary fix, probably related with https://bugs.launchpad.net/ubuntu/+source/opencv/+bug/1890170
# MySQL was failling with:
# from . import _mysql
# ImportError: /lib/x86_64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block
export LD_PRELOAD=/lib/x86_64-linux-gnu/libstdc++.so.6
export SUPERSET_CONFIG=${SUPERSET_CONFIG:-tests.integration_tests.superset_test_config}
export SUPERSET_TESTENV=true
echo "Superset config module: $SUPERSET_CONFIG"

View File

@@ -75,7 +75,7 @@ setup(
"cryptography>=3.3.2",
"deprecation>=2.1.0, <2.2.0",
"flask>=1.1.0, <2.0.0",
"flask-appbuilder>=3.3.3, <4.0.0",
"flask-appbuilder>=3.4.3, <4.0.0",
"flask-caching>=1.10.0",
"flask-compress",
"flask-talisman",
@@ -83,7 +83,7 @@ setup(
"flask-wtf",
"geopy",
"graphlib-backport",
"gunicorn>=20.0.2, <20.1",
"gunicorn>=20.1.0",
"holidays==0.10.3", # PINNED! https://github.com/dr-prodigy/python-holidays/issues/406
"humanize",
"itsdangerous>=1.0.0, <2.0.0", # https://github.com/apache/superset/pull/14627
@@ -102,11 +102,12 @@ setup(
"pyyaml>=5.4",
"PyJWT>=1.7.1, <2",
"redis",
"requests==2.26.0",
"selenium>=3.141.0",
"simplejson>=3.15.0",
"slackclient==2.5.0", # PINNED! slack changes file upload api in the future versions
"sqlalchemy>=1.3.16, <1.4, !=1.3.21",
"sqlalchemy-utils>=0.36.6, <0.37",
"sqlalchemy-utils>=0.37.8, <0.38",
"sqlparse==0.3.0", # PINNED! see https://github.com/andialbrecht/sqlparse/issues/562
"tabulate==0.8.9",
"typing-extensions>=3.10, <4", # needed to support Literal (3.8) and TypeGuard (3.10)
@@ -169,5 +170,6 @@ setup(
classifiers=[
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
],
)

View File

@@ -24,10 +24,10 @@ import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper';
function selectColorScheme(color: string) {
// open color scheme dropdown
cy.get('.modal-body')
.contains('Color Scheme')
cy.get('.ant-modal-body')
.contains('Color scheme')
.parents('.ControlHeader')
.next('.Select')
.next('.ant-select')
.click()
.then($colorSelect => {
// select a new color scheme
@@ -37,7 +37,7 @@ function selectColorScheme(color: string) {
function assertMetadata(text: string) {
const regex = new RegExp(text);
cy.get('.modal-body')
cy.get('.ant-modal-body')
.find('#json_metadata')
.should('be.visible')
.then(() => {
@@ -50,12 +50,15 @@ function assertMetadata(text: string) {
}
function typeMetadata(text: string) {
cy.get('.modal-body').find('#json_metadata').should('be.visible').type(text);
cy.get('.ant-modal-body')
.find('#json_metadata')
.should('be.visible')
.type(text);
}
function openAdvancedProperties() {
return cy
.get('.modal-body')
.get('.ant-modal-body')
.contains('Advanced')
.should('be.visible')
.click();
@@ -96,11 +99,11 @@ describe('Dashboard edit action', () => {
// save edit changes
cy.get('.ant-modal-footer')
.contains('Save')
.contains('Apply')
.click()
.then(() => {
// assert that modal edit window has closed
cy.get('.ant-modal-body').should('not.exist');
cy.get('.ant-modal-body').should('not.be.visible');
// assert title has been updated
cy.get('.editable-title input').should('have.value', dashboardTitle);
@@ -146,7 +149,7 @@ describe('Dashboard edit action', () => {
.click()
.then(() => {
// assert that modal edit window has closed
cy.get('.modal-body').should('not.exist');
cy.get('.ant-modal-body').should('not.exist');
// assert color has been updated
openDashboardEditProperties();
@@ -177,7 +180,7 @@ describe('Dashboard edit action', () => {
.click()
.then(() => {
// assert that modal edit window has closed
cy.get('.modal-body')
cy.get('.ant-modal-body')
.contains('A valid color scheme is required')
.should('be.visible');
});

View File

@@ -67,32 +67,30 @@ describe('Dashboard save action', () => {
// should load chart
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
// remove box_plot chart from dashboard
// remove treemap chart from dashboard
cy.get('[aria-label="edit-alt"]').click({ timeout: 5000 });
cy.get('[data-test="dashboard-delete-component-button"]')
.last()
.trigger('moustenter')
.trigger('mouseenter')
.click();
cy.get('[data-test="grid-container"]')
.find('.box_plot')
.should('not.exist');
cy.get('[data-test="grid-container"]').find('.treemap').should('not.exist');
cy.intercept('POST', '/superset/save_dash/**/').as('saveRequest');
cy.intercept('PUT', '/api/v1/dashboard/**').as('putDashboardRequest');
cy.get('[data-test="dashboard-header"]')
.find('[data-test="header-save-button"]')
.contains('Save')
.click();
// go back to view mode
cy.wait('@saveRequest');
cy.wait('@putDashboardRequest');
cy.get('[data-test="dashboard-header"]')
.find('[aria-label="edit-alt"]')
.click();
// deleted boxplot should still not exist
// deleted treemap should still not exist
cy.get('[data-test="grid-container"]')
.find('.box_plot', { timeout: 20000 })
.find('.treemap', { timeout: 20000 })
.should('not.exist');
});

View File

@@ -62,7 +62,7 @@ describe('Visualization > Table', () => {
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P0.25Y',
time_grain_sqla: 'P3M',
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
});
// when format with smart_date, time column use format by granularity
@@ -77,7 +77,7 @@ describe('Visualization > Table', () => {
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P0.25Y',
time_grain_sqla: 'P3M',
table_timestamp_format: '%Y-%m-%d %H:%M',
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
});
@@ -111,7 +111,7 @@ describe('Visualization > Table', () => {
...VIZ_DEFAULTS,
include_time: true,
granularity_sqla: 'ds',
time_grain_sqla: 'P0.25Y',
time_grain_sqla: 'P3M',
metrics: [NUM_METRIC, MAX_DS],
groupby: ['name'],
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "superset",
"version": "0.0.0dev",
"version": "1.4.0",
"description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.",
"license": "Apache-2.0",
"directories": {
@@ -16,7 +16,7 @@
"dev-server": "cross-env NODE_ENV=development BABEL_ENV=development node --max_old_space_size=4096 ./node_modules/webpack-dev-server/bin/webpack-dev-server.js --mode=development",
"prod": "npm run build",
"build-dev": "cross-env NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=development webpack --mode=development --color",
"build-instrumented": "cross-env NODE_ENV=development BABEL_ENV=instrumented webpack --mode=development --color",
"build-instrumented": "cross-env NODE_ENV=production BABEL_ENV=instrumented webpack --mode=production --color",
"build": "cross-env NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=production BABEL_ENV=\"${BABEL_ENV:=production}\" webpack --mode=production --color",
"lint": "eslint --ignore-path=.eslintignore --ext .js,.jsx,.ts,.tsx . && npm run type",
"prettier-check": "prettier --check 'src/**/*.{css,less,sass,scss}'",
@@ -68,35 +68,35 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@superset-ui/chart-controls": "^0.18.8",
"@superset-ui/core": "^0.18.8",
"@superset-ui/legacy-plugin-chart-calendar": "^0.18.8",
"@superset-ui/legacy-plugin-chart-chord": "^0.18.8",
"@superset-ui/legacy-plugin-chart-country-map": "^0.18.8",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.18.8",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.18.8",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.18.8",
"@superset-ui/legacy-plugin-chart-histogram": "^0.18.8",
"@superset-ui/legacy-plugin-chart-horizon": "^0.18.8",
"@superset-ui/legacy-plugin-chart-map-box": "^0.18.8",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.18.8",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.18.8",
"@superset-ui/legacy-plugin-chart-partition": "^0.18.8",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.18.8",
"@superset-ui/legacy-plugin-chart-rose": "^0.18.8",
"@superset-ui/legacy-plugin-chart-sankey": "^0.18.8",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.18.8",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.18.8",
"@superset-ui/legacy-plugin-chart-treemap": "^0.18.8",
"@superset-ui/legacy-plugin-chart-world-map": "^0.18.8",
"@superset-ui/legacy-preset-chart-big-number": "^0.18.8",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.12",
"@superset-ui/legacy-preset-chart-nvd3": "^0.18.8",
"@superset-ui/plugin-chart-echarts": "^0.18.8",
"@superset-ui/plugin-chart-pivot-table": "^0.18.8",
"@superset-ui/plugin-chart-table": "^0.18.8",
"@superset-ui/plugin-chart-word-cloud": "^0.18.8",
"@superset-ui/preset-chart-xy": "^0.18.8",
"@superset-ui/chart-controls": "^0.18.19",
"@superset-ui/core": "^0.18.19",
"@superset-ui/legacy-plugin-chart-calendar": "^0.18.19",
"@superset-ui/legacy-plugin-chart-chord": "^0.18.19",
"@superset-ui/legacy-plugin-chart-country-map": "^0.18.19",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.18.19",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.18.19",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.18.19",
"@superset-ui/legacy-plugin-chart-histogram": "^0.18.19",
"@superset-ui/legacy-plugin-chart-horizon": "^0.18.19",
"@superset-ui/legacy-plugin-chart-map-box": "^0.18.19",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.18.19",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.18.19",
"@superset-ui/legacy-plugin-chart-partition": "^0.18.19",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.18.19",
"@superset-ui/legacy-plugin-chart-rose": "^0.18.19",
"@superset-ui/legacy-plugin-chart-sankey": "^0.18.19",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.18.19",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.18.19",
"@superset-ui/legacy-plugin-chart-treemap": "^0.18.19",
"@superset-ui/legacy-plugin-chart-world-map": "^0.18.19",
"@superset-ui/legacy-preset-chart-big-number": "^0.18.19",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.13",
"@superset-ui/legacy-preset-chart-nvd3": "^0.18.19",
"@superset-ui/plugin-chart-echarts": "^0.18.19",
"@superset-ui/plugin-chart-pivot-table": "^0.18.19",
"@superset-ui/plugin-chart-table": "^0.18.19",
"@superset-ui/plugin-chart-word-cloud": "^0.18.19",
"@superset-ui/preset-chart-xy": "^0.18.19",
"@vx/responsive": "^0.0.195",
"abortcontroller-polyfill": "^1.1.9",
"antd": "^4.9.4",
@@ -139,7 +139,7 @@
"query-string": "^6.13.7",
"re-resizable": "^6.6.1",
"react": "^16.13.1",
"react-ace": "^5.10.0",
"react-ace": "^9.4.4",
"react-checkbox-tree": "^1.5.1",
"react-color": "^2.13.8",
"react-datetime": "^3.0.4",

View File

@@ -168,6 +168,7 @@ export default {
id,
granularity_sqla: [['ds', 'ds']],
name: 'birth_names',
owners: [{ first_name: 'joe', last_name: 'man', id: 1 }],
database: {
allow_multi_schema_metadata_fetch: null,
name: 'main',

View File

@@ -139,7 +139,6 @@ describe('FiltersBadge', () => {
wrapper.find('[data-test="incompatible-filter-count"]'),
).toHaveText('1');
// to look at the shape of the wrapper use:
// console.log(wrapper.debug())
expect(wrapper.find(Icons.AlertSolid)).toExist();
});
});

View File

@@ -59,7 +59,8 @@ fetchMock.get('glob:*/api/v1/dashboard/*', {
},
});
describe('PropertiesModal', () => {
// all these tests need to be moved to dashboard/components/PropertiesModal/PropertiesModal.test.tsx
describe.skip('PropertiesModal', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.resetAllMocks();
@@ -94,10 +95,12 @@ describe('PropertiesModal', () => {
describe('without metadata', () => {
const wrapper = setup({ colorScheme: 'SUPERSET_DEFAULT' });
const modalInstance = wrapper.find('PropertiesModal').instance();
it('does not update the color scheme in the metadata', () => {
it('updates the color scheme in the metadata', () => {
const spy = jest.spyOn(modalInstance, 'onMetadataChange');
modalInstance.onColorSchemeChange('SUPERSET_DEFAULT');
expect(spy).not.toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(
'{"something": "foo", "color_scheme": "SUPERSET_DEFAULT", "label_colors": {}}',
);
});
});
describe('with metadata', () => {
@@ -125,10 +128,12 @@ describe('PropertiesModal', () => {
json_metadata: '{"timed_refresh_immune_slices": []}',
},
});
it('will not update the metadata', () => {
it('will update the metadata', () => {
const spy = jest.spyOn(modalInstance, 'onMetadataChange');
modalInstance.onColorSchemeChange('SUPERSET_DEFAULT');
expect(spy).not.toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(
'{"something": "foo", "color_scheme": "SUPERSET_DEFAULT", "label_colors": {}}',
);
});
});
});

View File

@@ -116,7 +116,7 @@ describe('Chart', () => {
expect(stubbedExportCSV.lastCall.args[0]).toEqual(
expect.objectContaining({
formData: expect.anything(),
resultType: 'results',
resultType: 'full',
resultFormat: 'csv',
}),
);

View File

@@ -17,14 +17,13 @@
* under the License.
*/
import React from 'react';
import { render } from 'spec/helpers/testing-library';
import { shallow } from 'enzyme';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
describe('HoverMenu', () => {
it('should render a hover menu', () => {
const rendered = render(<HoverMenu />);
const hoverMenu = rendered.container.querySelector('.hover-menu');
expect(hoverMenu).toBeVisible();
it('should render a div.hover-menu', () => {
const wrapper = shallow(<HoverMenu />);
expect(wrapper.find('.hover-menu')).toExist();
});
});

View File

@@ -69,9 +69,17 @@ enum LIMITING_FACTOR {
const LOADING_STYLES: CSSProperties = { position: 'relative', minHeight: 100 };
interface DatasetOwner {
first_name: string;
id: number;
last_name: string;
username: string;
}
interface DatasetOptionAutocomplete {
value: string;
datasetId: number;
owners: [DatasetOwner];
}
interface ResultSetProps {
@@ -142,6 +150,7 @@ const updateDataset = async (
datasetId: number,
sql: string,
columns: Array<Record<string, any>>,
owners: [number],
overrideColumns: boolean,
) => {
const endpoint = `api/v1/dataset/${datasetId}?override_columns=${overrideColumns}`;
@@ -149,6 +158,7 @@ const updateDataset = async (
const body = JSON.stringify({
sql,
columns,
owners,
});
const data: JsonResponse = await SupersetClient.put({
@@ -269,6 +279,7 @@ export default class ResultSet extends React.PureComponent<
datasetToOverwrite.datasetId,
sql,
results.selected_columns.map(d => ({ column_name: d.name })),
datasetToOverwrite.owners.map((o: DatasetOwner) => o.id),
true,
);
@@ -405,10 +416,13 @@ export default class ResultSet extends React.PureComponent<
endpoint: '/api/v1/dataset',
})(`q=${queryParams}`);
return response.result.map((r: { table_name: string; id: number }) => ({
value: r.table_name,
datasetId: r.id,
}));
return response.result.map(
(r: { table_name: string; id: number; owners: [DatasetOwner] }) => ({
value: r.table_name,
datasetId: r.id,
owners: r.owners,
}),
);
}
return null;

View File

@@ -77,6 +77,7 @@ const Fade = styled.div`
const TableElement = ({ table, actions, ...props }: TableElementProps) => {
const [sortColumns, setSortColumns] = useState(false);
const [hovered, setHovered] = useState(false);
const tableNameRef = React.useRef<HTMLInputElement>(null);
const setHover = (hovered: boolean) => {
debounce(() => setHovered(hovered), 100)();
@@ -213,39 +214,50 @@ const TableElement = ({ table, actions, ...props }: TableElementProps) => {
);
};
const renderHeader = () => (
<div
className="clearfix header-container"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<Tooltip
id="copy-to-clipboard-tooltip"
placement="topLeft"
style={{ cursor: 'pointer' }}
title={table.name}
trigger={['hover']}
>
<StyledSpan data-test="collapse" className="table-name">
<strong>{table.name}</strong>
</StyledSpan>
</Tooltip>
const renderHeader = () => {
const element: HTMLInputElement | null = tableNameRef.current;
let trigger: string[] = [];
if (element && element.offsetWidth < element.scrollWidth) {
trigger = ['hover'];
}
<div className="pull-right header-right-side">
{table.isMetadataLoading || table.isExtraMetadataLoading ? (
<Loading position="inline" />
) : (
<Fade
data-test="fade"
hovered={hovered}
onClick={e => e.stopPropagation()}
return (
<div
className="clearfix header-container"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<Tooltip
id="copy-to-clipboard-tooltip"
style={{ cursor: 'pointer' }}
title={table.name}
trigger={trigger}
>
<StyledSpan
data-test="collapse"
ref={tableNameRef}
className="table-name"
>
{renderControls()}
</Fade>
)}
<strong>{table.name}</strong>
</StyledSpan>
</Tooltip>
<div className="pull-right header-right-side">
{table.isMetadataLoading || table.isExtraMetadataLoading ? (
<Loading position="inline" />
) : (
<Fade
data-test="fade"
hovered={hovered}
onClick={e => e.stopPropagation()}
>
{renderControls()}
</Fade>
)}
</div>
</div>
</div>
);
);
};
const renderBody = () => {
let cols;

View File

@@ -51,7 +51,7 @@ describe('TemplateParamsEditor', () => {
const spy = jest.spyOn(brace, 'acequire');
spy.mockReturnValue({ setCompleters: () => 'foo' });
await waitFor(() => {
expect(baseElement.querySelector('#brace-editor')).toBeInTheDocument();
expect(baseElement.querySelector('#ace-editor')).toBeInTheDocument();
});
});
});

View File

@@ -207,7 +207,7 @@ div.Workspace {
flex-direction: column;
}
#brace-editor {
#ace-editor {
height: calc(100% - 51px);
flex-grow: 1;
}

View File

@@ -44,6 +44,7 @@ const propTypes = {
// formData contains chart's own filter parameter
// and merged with extra filter that current dashboard applying
formData: PropTypes.object.isRequired,
labelColors: PropTypes.object,
width: PropTypes.number,
height: PropTypes.number,
setControlValue: PropTypes.func,

View File

@@ -29,6 +29,7 @@ const propTypes = {
datasource: PropTypes.object,
initialValues: PropTypes.object,
formData: PropTypes.object.isRequired,
labelColors: PropTypes.object,
height: PropTypes.number,
width: PropTypes.number,
setControlValue: PropTypes.func,
@@ -100,6 +101,7 @@ class ChartRenderer extends React.Component {
nextProps.height !== this.props.height ||
nextProps.width !== this.props.width ||
nextProps.triggerRender ||
nextProps.labelColors !== this.props.labelColors ||
nextProps.formData.color_scheme !== this.props.formData.color_scheme ||
nextProps.cacheBusterProp !== this.props.cacheBusterProp
);

View File

@@ -395,13 +395,16 @@ export function exploreJSON(
.then(({ response, json }) => {
if (isFeatureEnabled(FeatureFlag.GLOBAL_ASYNC_QUERIES)) {
// deal with getChartDataRequest transforming the response data
const result = 'result' in json ? json.result[0] : json;
const result = 'result' in json ? json.result : json;
switch (response.status) {
case 200:
// Query results returned synchronously, meaning query was already cached.
return Promise.resolve([result]);
return Promise.resolve(result);
case 202:
// Query is running asynchronously and we must await the results
if (shouldUseLegacyApi(formData)) {
return waitForAsyncData(result[0]);
}
return waitForAsyncData(result);
default:
throw new Error(

View File

@@ -30,7 +30,7 @@ import AsyncAceEditor, {
AsyncAceEditorOptions,
} from 'src/components/AsyncAceEditor';
const selector = '[id="brace-editor"]';
const selector = '[id="ace-editor"]';
test('renders SQLEditor', async () => {
const { container } = render(<SQLEditor />);

View File

@@ -23,7 +23,7 @@ import {
Position,
TextMode as OrigTextMode,
} from 'brace';
import AceEditor, { AceEditorProps } from 'react-ace';
import AceEditor, { IAceEditorProps } from 'react-ace';
import AsyncEsmComponent, {
PlaceholderProps,
} from 'src/components/AsyncEsmComponent';
@@ -72,7 +72,7 @@ const aceModuleLoaders = {
export type AceModule = keyof typeof aceModuleLoaders;
export type AsyncAceEditorProps = AceEditorProps & {
export type AsyncAceEditorProps = IAceEditorProps & {
keywords?: AceCompleterKeyword[];
};
@@ -83,7 +83,7 @@ export type AsyncAceEditorOptions = {
defaultTheme?: AceEditorTheme;
defaultTabSize?: number;
placeholder?: React.ComponentType<
PlaceholderProps & Partial<AceEditorProps>
PlaceholderProps & Partial<IAceEditorProps>
> | null;
};
@@ -120,7 +120,6 @@ export default function AsyncAceEditor(
theme = inferredTheme,
tabSize = defaultTabSize,
defaultValue = '',
value = '',
...props
},
ref,
@@ -153,7 +152,6 @@ export default function AsyncAceEditor(
theme={theme}
tabSize={tabSize}
defaultValue={defaultValue}
value={value || ''}
{...props}
/>
);

View File

@@ -26,7 +26,12 @@ import DatabaseSelector from '.';
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
const createProps = () => ({
db: { id: 1, database_name: 'test', backend: 'test-postgresql' },
db: {
id: 1,
database_name: 'test',
backend: 'test-postgresql',
allow_multi_schema_metadata_fetch: false,
},
formMode: false,
isDatabaseSelectEnabled: true,
readOnly: false,
@@ -246,6 +251,7 @@ test('Sends the correct db when changing the database', async () => {
id: 2,
database_name: 'test-mysql',
backend: 'mysql',
allow_multi_schema_metadata_fetch: false,
}),
),
);

View File

@@ -63,21 +63,25 @@ type DatabaseValue = {
id: number;
database_name: string;
backend: string;
allow_multi_schema_metadata_fetch: boolean;
};
export type DatabaseObject = {
id: number;
database_name: string;
backend: string;
allow_multi_schema_metadata_fetch: boolean;
};
type SchemaValue = { label: string; value: string };
interface DatabaseSelectorProps {
db?: { id: number; database_name: string; backend: string };
db?: DatabaseObject;
formMode?: boolean;
getDbList?: (arg0: any) => {};
handleError: (msg: string) => void;
isDatabaseSelectEnabled?: boolean;
onDbChange?: (db: {
id: number;
database_name: string;
backend: string;
}) => void;
onDbChange?: (db: DatabaseObject) => void;
onSchemaChange?: (schema?: string) => void;
onSchemasLoad?: (schemas: Array<object>) => void;
readOnly?: boolean;
@@ -165,20 +169,20 @@ export default function DatabaseSelector({
if (result.length === 0) {
handleError(t("It seems you don't have access to any database"));
}
const options = result.map(
(row: { id: number; database_name: string; backend: string }) => ({
label: (
<SelectLabel
backend={row.backend}
databaseName={row.database_name}
/>
),
value: row.id,
id: row.id,
database_name: row.database_name,
backend: row.backend,
}),
);
const options = result.map((row: DatabaseObject) => ({
label: (
<SelectLabel
backend={row.backend}
databaseName={row.database_name}
/>
),
value: row.id,
id: row.id,
database_name: row.database_name,
backend: row.backend,
allow_multi_schema_metadata_fetch:
row.allow_multi_schema_metadata_fetch,
}));
return {
data: options,
totalCount: options.length,
@@ -194,9 +198,9 @@ export default function DatabaseSelector({
const queryParams = rison.encode({ force: refresh > 0 });
const endpoint = `/api/v1/database/${currentDb.value}/schemas/?q=${queryParams}`;
try {
// TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes.
SupersetClient.get({ endpoint }).then(({ json }) => {
// TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes.
SupersetClient.get({ endpoint })
.then(({ json }) => {
const options = json.result
.map((s: string) => ({
value: s,
@@ -210,10 +214,12 @@ export default function DatabaseSelector({
onSchemasLoad(options);
}
setSchemaOptions(options);
setLoadingSchemas(false);
})
.catch(e => {
setLoadingSchemas(false);
handleError(t('There was an error loading the schemas'));
});
} finally {
setLoadingSchemas(false);
}
}
}, [currentDb, onSchemasLoad, refresh]);
@@ -251,6 +257,7 @@ export default function DatabaseSelector({
return renderSelectRow(
<Select
ariaLabel={t('Select database or type database name')}
optionFilterProps={['database_name', 'value']}
data-test="select-database"
header={<FormLabel>{t('Database')}</FormLabel>}
lazyLoading={false}

View File

@@ -44,13 +44,23 @@ test('Calling "onHide"', () => {
onHide: jest.fn(),
open: true,
};
render(<DeleteModal {...props} />);
const modal = <DeleteModal {...props} />;
render(modal);
expect(props.onHide).toBeCalledTimes(0);
expect(props.onConfirm).toBeCalledTimes(0);
// type "del" in the input
userEvent.type(screen.getByTestId('delete-modal-input'), 'del');
expect(screen.getByTestId('delete-modal-input')).toHaveValue('del');
// close the modal
expect(screen.getByText('×')).toBeVisible();
userEvent.click(screen.getByText('×'));
expect(props.onHide).toBeCalledTimes(1);
expect(props.onConfirm).toBeCalledTimes(0);
// confirm input has been cleared
expect(screen.getByTestId('delete-modal-input')).toHaveValue('');
});
test('Calling "onConfirm" only after typing "delete" in the input', () => {
@@ -75,4 +85,7 @@ test('Calling "onConfirm" only after typing "delete" in the input', () => {
userEvent.type(screen.getByTestId('delete-modal-input'), 'delete');
userEvent.click(screen.getByText('delete'));
expect(props.onConfirm).toBeCalledTimes(1);
// confirm input has been cleared
expect(screen.getByTestId('delete-modal-input')).toHaveValue('');
});

View File

@@ -52,12 +52,35 @@ export default function DeleteModal({
title,
}: DeleteModalProps) {
const [disableChange, setDisableChange] = useState(true);
const [confirmation, setConfirmation] = useState<string>('');
const hide = () => {
setConfirmation('');
onHide();
};
const confirm = () => {
setConfirmation('');
onConfirm();
};
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const targetValue = event.target.value ?? '';
setDisableChange(targetValue.toUpperCase() !== t('DELETE'));
setConfirmation(targetValue);
};
const onPressEnter = () => {
if (!disableChange) {
confirm();
}
};
return (
<Modal
disablePrimaryButton={disableChange}
onHide={onHide}
onHandledPrimaryAction={onConfirm}
onHide={hide}
onHandledPrimaryAction={confirm}
primaryButtonName={t('delete')}
primaryButtonType="danger"
show={open}
@@ -73,10 +96,9 @@ export default function DeleteModal({
type="text"
id="delete"
autoComplete="off"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const targetValue = event.target.value ?? '';
setDisableChange(targetValue.toUpperCase() !== t('DELETE'));
}}
value={confirmation}
onChange={onChange}
onPressEnter={onPressEnter}
/>
</StyledDiv>
</Modal>

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { styled, supersetTheme } from '@superset-ui/core';
import { styled, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { ErrorLevel } from './types';
@@ -51,15 +51,18 @@ interface BasicErrorAlertProps {
export default function BasicErrorAlert({
body,
level,
level = 'error',
title,
}: BasicErrorAlertProps) {
const theme = useTheme();
const iconColor = theme.colors[level].base;
return (
<StyledContainer level={level} role="alert">
{level === 'error' ? (
<Icons.ErrorSolid iconColor={supersetTheme.colors[level].base} />
<Icons.ErrorSolid iconColor={iconColor} />
) : (
<Icons.WarningSolid iconColor={supersetTheme.colors[level].base} />
<Icons.WarningSolid iconColor={iconColor} />
)}
<StyledContent>
<StyledTitle>{title}</StyledTitle>

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React, { useState, ReactNode } from 'react';
import { styled, supersetTheme, t } from '@superset-ui/core';
import { styled, useTheme, t } from '@superset-ui/core';
import { noOp } from 'src/utils/common';
import Modal from 'src/components/Modal';
import Button from 'src/components/Button';
@@ -92,30 +92,27 @@ interface ErrorAlertProps {
export default function ErrorAlert({
body,
copyText,
level,
level = 'error',
source = 'dashboard',
subtitle,
title,
}: ErrorAlertProps) {
const theme = useTheme();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isBodyExpanded, setIsBodyExpanded] = useState(false);
const isExpandable = ['explore', 'sqllab'].includes(source);
const iconColor = theme.colors[level].base;
return (
<ErrorAlertDiv level={level} role="alert">
<div className="top-row">
<LeftSideContent>
{level === 'error' ? (
<Icons.ErrorSolid
className="icon"
iconColor={supersetTheme.colors[level].base}
/>
<Icons.ErrorSolid className="icon" iconColor={iconColor} />
) : (
<Icons.WarningSolid
className="icon"
iconColor={supersetTheme.colors[level].base}
/>
<Icons.WarningSolid className="icon" iconColor={iconColor} />
)}
<strong>{title}</strong>
</LeftSideContent>
@@ -170,15 +167,9 @@ export default function ErrorAlert({
title={
<div className="header">
{level === 'error' ? (
<Icons.ErrorSolid
className="icon"
iconColor={supersetTheme.colors[level].base}
/>
<Icons.ErrorSolid className="icon" iconColor={iconColor} />
) : (
<Icons.WarningSolid
className="icon"
iconColor={supersetTheme.colors[level].base}
/>
<Icons.WarningSolid className="icon" iconColor={iconColor} />
)}
<div className="title">{title}</div>
</div>

View File

@@ -56,7 +56,7 @@ function ParameterErrorMessage({
source = 'sqllab',
subtitle,
}: ErrorMessageComponentProps<ParameterErrorExtra>) {
const { extra, level, message } = error;
const { extra = { issue_codes: [] }, level, message } = error;
const triggerMessage = tn(
'This was triggered by:',
@@ -99,9 +99,10 @@ function ParameterErrorMessage({
)}
{triggerMessage}
<br />
{extra.issue_codes
.map<React.ReactNode>(issueCode => <IssueCode {...issueCode} />)
.reduce((prev, curr) => [prev, <br />, curr])}
{extra.issue_codes.length > 0 &&
extra.issue_codes
.map<React.ReactNode>(issueCode => <IssueCode {...issueCode} />)
.reduce((prev, curr) => [prev, <br />, curr])}
</p>
</>
);

View File

@@ -359,7 +359,12 @@ export default class FilterableTable extends PureComponent<
? 'header-style-disabled'
: 'header-style';
return (
<Tooltip id="header-tooltip" title={label}>
<Tooltip
id="header-tooltip"
title={label}
placement="topLeft"
css={{ display: 'block' }}
>
<div className={className}>
{label}
{sortBy === dataKey && (
@@ -385,7 +390,13 @@ export default class FilterableTable extends PureComponent<
? 'header-style-disabled'
: 'header-style';
return (
<Tooltip key={key} id="header-tooltip" title={label}>
<Tooltip
key={key}
id="header-tooltip"
title={label}
placement="topLeft"
css={{ display: 'block' }}
>
<div
style={{
...style,
@@ -435,7 +446,7 @@ export default class FilterableTable extends PureComponent<
}}
className={`grid-cell ${this.rowClassName({ index: rowIndex })}`}
>
<div>{content}</div>
<div css={{ width: 'inherit' }}>{content}</div>
</div>
);

View File

@@ -22,6 +22,8 @@ import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import { styledMount as mount } from 'spec/helpers/theming';
import { ReactWrapper } from 'enzyme';
import fetchMock from 'fetch-mock';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { Upload } from 'src/common/components';
import Button from 'src/components/Button';
import { ImportResourceName } from 'src/views/CRUD/types';
@@ -31,6 +33,10 @@ import Modal from 'src/components/Modal';
const mockStore = configureStore([thunk]);
const store = mockStore({});
const DATABASE_IMPORT_URL = 'glob:*/api/v1/database/import/';
fetchMock.config.overwriteRoutes = true;
fetchMock.post(DATABASE_IMPORT_URL, { result: 'OK' });
const requiredProps = {
resourceName: 'database' as ImportResourceName,
resourceLabel: 'database',
@@ -101,6 +107,33 @@ describe('ImportModelsModal', () => {
expect(wrapper.find(Button).at(2).prop('disabled')).toBe(false);
});
it('should POST with request header `Accept: application/json`', async () => {
const file = new File([new ArrayBuffer(1)], 'model_export.zip');
act(() => {
const handler = wrapper.find(Upload).prop('onChange');
if (handler) {
handler({
fileList: [],
file: {
name: 'model_export.zip',
originFileObj: file,
uid: '-1',
size: 0,
type: 'zip',
},
});
}
});
wrapper.update();
wrapper.find(Button).at(2).simulate('click');
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(DATABASE_IMPORT_URL)[0][1]?.headers).toStrictEqual({
Accept: 'application/json',
'X-CSRFToken': '1234',
});
});
it('should render password fields when needed for import', () => {
const wrapperWithPasswords = mount(
<ImportModelsModal

View File

@@ -111,4 +111,6 @@ export enum FilterOperator {
between = 'between',
dashboardIsFav = 'dashboard_is_favorite',
chartIsFav = 'chart_is_favorite',
chartIsCertified = 'chart_is_certified',
dashboardIsCertified = 'dashboard_is_certified',
}

View File

@@ -21,6 +21,7 @@ import { styled, useTheme } from '@superset-ui/core';
import { AntdCard, Skeleton, ThinSkeleton } from 'src/common/components';
import { Tooltip } from 'src/components/Tooltip';
import ImageLoader, { BackgroundPosition } from './ImageLoader';
import CertifiedIcon from '../CertifiedIcon';
const ActionsWrapper = styled.div`
width: 64px;
@@ -161,6 +162,8 @@ interface CardProps {
rows?: number | string;
avatar?: React.ReactElement | null;
cover?: React.ReactNode | null;
certifiedBy?: string;
certificationDetails?: string;
}
function ListViewCard({
@@ -178,6 +181,8 @@ function ListViewCard({
loading,
imgPosition = 'top',
cover,
certifiedBy,
certificationDetails,
}: CardProps) {
const Link = url && linkComponent ? linkComponent : AnchorLink;
const theme = useTheme();
@@ -249,7 +254,17 @@ function ListViewCard({
<TitleContainer>
<Tooltip title={title}>
<TitleLink>
<Link to={url!}>{title}</Link>
<Link to={url!}>
{certifiedBy && (
<>
<CertifiedIcon
certifiedBy={certifiedBy}
details={certificationDetails}
/>{' '}
</>
)}
{title}
</Link>
</TitleLink>
</Tooltip>
{titleRight && <TitleRight>{titleRight}</TitleRight>}

View File

@@ -17,32 +17,12 @@
* under the License.
*/
import React from 'react';
import * as reactRedux from 'react-redux';
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { Menu } from './Menu';
import { dropdownItems } from './MenuRight';
const user = {
createdOn: '2021-04-27T18:12:38.952304',
email: 'admin',
firstName: 'admin',
isActive: true,
lastName: 'admin',
permissions: {},
roles: {
Admin: [
['can_sqllab', 'Superset'],
['can_write', 'Dashboard'],
['can_write', 'Chart'],
],
},
userId: 1,
username: 'admin',
};
const mockedProps = {
user,
data: {
menu: [
{
@@ -156,27 +136,17 @@ const notanonProps = {
},
};
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
beforeEach(() => {
// setup a DOM element as a render target
useSelectorMock.mockClear();
});
test('should render', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const { container } = render(<Menu {...mockedProps} />);
expect(container).toBeInTheDocument();
});
test('should render the navigation', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...mockedProps} />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
test('should render the brand', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: {
brand: { alt, icon },
@@ -188,7 +158,6 @@ test('should render the brand', () => {
});
test('should render all the top navbar menu items', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: { menu },
} = mockedProps;
@@ -199,7 +168,6 @@ test('should render all the top navbar menu items', () => {
});
test('should render the top navbar child menu items', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: { menu },
} = mockedProps;
@@ -216,7 +184,6 @@ test('should render the top navbar child menu items', async () => {
});
test('should render the dropdown items', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...notanonProps} />);
const dropdown = screen.getByTestId('new-dropdown-icon');
userEvent.hover(dropdown);
@@ -244,14 +211,12 @@ test('should render the dropdown items', async () => {
});
test('should render the Settings', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...mockedProps} />);
const settings = await screen.findByText('Settings');
expect(settings).toBeInTheDocument();
});
test('should render the Settings menu item', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...mockedProps} />);
userEvent.hover(screen.getByText('Settings'));
const label = await screen.findByText('Security');
@@ -259,7 +224,6 @@ test('should render the Settings menu item', async () => {
});
test('should render the Settings dropdown child menu items', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: { settings },
} = mockedProps;
@@ -270,19 +234,16 @@ test('should render the Settings dropdown child menu items', async () => {
});
test('should render the plus menu (+) when user is not anonymous', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...notanonProps} />);
expect(screen.getByTestId('new-dropdown')).toBeInTheDocument();
});
test('should NOT render the plus menu (+) when user is anonymous', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...mockedProps} />);
expect(screen.queryByTestId('new-dropdown')).not.toBeInTheDocument();
});
test('should render the user actions when user is not anonymous', async () => {
useSelectorMock.mockReturnValue({ roles: mockedProps.user.roles });
const {
data: {
navbar_right: { user_info_url, user_logout_url },
@@ -302,13 +263,11 @@ test('should render the user actions when user is not anonymous', async () => {
});
test('should NOT render the user actions when user is anonymous', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...mockedProps} />);
expect(screen.queryByText('User')).not.toBeInTheDocument();
});
test('should render the Profile link when available', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: {
navbar_right: { user_profile_url },
@@ -323,7 +282,6 @@ test('should render the Profile link when available', async () => {
});
test('should render the About section and version_string, sha or build_number when available', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: {
navbar_right: { version_sha, version_string, build_number },
@@ -343,7 +301,6 @@ test('should render the About section and version_string, sha or build_number wh
});
test('should render the Documentation link when available', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: {
navbar_right: { documentation_url },
@@ -356,7 +313,6 @@ test('should render the Documentation link when available', async () => {
});
test('should render the Bug Report link when available', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: {
navbar_right: { bug_report_url },
@@ -369,7 +325,6 @@ test('should render the Bug Report link when available', async () => {
});
test('should render the Login link when user is anonymous', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
const {
data: {
navbar_right: { user_login_url },
@@ -382,13 +337,6 @@ test('should render the Login link when user is anonymous', () => {
});
test('should render the Language Picker', () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
render(<Menu {...mockedProps} />);
expect(screen.getByLabelText('Languages')).toBeInTheDocument();
});
test('should hide create button without proper roles', () => {
useSelectorMock.mockReturnValue({ roles: [] });
render(<Menu {...notanonProps} />);
expect(screen.queryByTestId('new-dropdown')).not.toBeInTheDocument();
});

View File

@@ -21,9 +21,6 @@ import { MainNav as Menu } from 'src/common/components';
import { t, styled, css, SupersetTheme } from '@superset-ui/core';
import { Link } from 'react-router-dom';
import Icons from 'src/components/Icons';
import findPermission from 'src/dashboard/util/findPermission';
import { useSelector } from 'react-redux';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import LanguagePicker from './LanguagePicker';
import { NavBarProps, MenuObjectProps } from './Menu';
@@ -32,22 +29,16 @@ export const dropdownItems = [
label: t('SQL query'),
url: '/superset/sqllab?new=true',
icon: 'fa-fw fa-search',
perm: 'can_sqllab',
view: 'Superset',
},
{
label: t('Chart'),
url: '/chart/add',
icon: 'fa-fw fa-bar-chart',
perm: 'can_write',
view: 'Dashboard',
},
{
label: t('Dashboard'),
url: '/dashboard/new',
icon: 'fa-fw fa-dashboard',
perm: 'can_write',
view: 'Chart',
},
];
@@ -92,146 +83,134 @@ const RightMenu = ({
settings,
navbarRight,
isFrontendRoute,
}: RightMenuProps) => {
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
// if user has any of these roles the dropdown will appear
const canSql = findPermission('can_sqllab', 'Superset', roles);
const canDashboard = findPermission('can_write', 'Dashboard', roles);
const canChart = findPermission('can_write', 'Chart', roles);
const showActionDropdown = canSql || canChart || canDashboard;
return (
<StyledDiv align={align}>
<Menu mode="horizontal">
{!navbarRight.user_is_anonymous && showActionDropdown && (
<SubMenu
data-test="new-dropdown"
title={
<StyledI data-test="new-dropdown-icon" className="fa fa-plus" />
}
icon={<Icons.TriangleDown />}
>
{dropdownItems.map(
menu =>
findPermission(menu.perm, menu.view, roles) && (
<Menu.Item key={menu.label}>
<a href={menu.url}>
<i
data-test={`menu-item-${menu.label}`}
className={`fa ${menu.icon}`}
/>{' '}
{menu.label}
</a>
</Menu.Item>
),
)}
</SubMenu>
)}
<SubMenu title="Settings" icon={<Icons.TriangleDown iconSize="xl" />}>
{settings.map((section, index) => [
<Menu.ItemGroup key={`${section.label}`} title={section.label}>
{section.childs?.map(child => {
if (typeof child !== 'string') {
return (
<Menu.Item key={`${child.label}`}>
{isFrontendRoute(child.url) ? (
<Link to={child.url || ''}>{child.label}</Link>
) : (
<a href={child.url}>{child.label}</a>
)}
</Menu.Item>
);
}
return null;
})}
</Menu.ItemGroup>,
index < settings.length - 1 && <Menu.Divider />,
])}
{!navbarRight.user_is_anonymous && [
<Menu.Divider key="user-divider" />,
<Menu.ItemGroup key="user-section" title={t('User')}>
{navbarRight.user_profile_url && (
<Menu.Item key="profile">
<a href={navbarRight.user_profile_url}>{t('Profile')}</a>
</Menu.Item>
)}
{navbarRight.user_info_url && (
<Menu.Item key="info">
<a href={navbarRight.user_info_url}>{t('Info')}</a>
</Menu.Item>
)}
<Menu.Item key="logout">
<a href={navbarRight.user_logout_url}>{t('Logout')}</a>
</Menu.Item>
</Menu.ItemGroup>,
]}
{(navbarRight.version_string || navbarRight.version_sha) && [
<Menu.Divider key="version-info-divider" />,
<Menu.ItemGroup key="about-section" title={t('About')}>
<div className="about-section">
{navbarRight.show_watermark && (
<div css={versionInfoStyles}>
{t('Powered by Apache Superset')}
</div>
)}
{navbarRight.version_string && (
<div css={versionInfoStyles}>
Version: {navbarRight.version_string}
</div>
)}
{navbarRight.version_sha && (
<div css={versionInfoStyles}>
SHA: {navbarRight.version_sha}
</div>
)}
{navbarRight.build_number && (
<div css={versionInfoStyles}>
Build: {navbarRight.build_number}
</div>
)}
</div>
</Menu.ItemGroup>,
]}
}: RightMenuProps) => (
<StyledDiv align={align}>
<Menu mode="horizontal">
{!navbarRight.user_is_anonymous && (
<SubMenu
data-test="new-dropdown"
title={
<StyledI data-test="new-dropdown-icon" className="fa fa-plus" />
}
icon={<Icons.TriangleDown />}
>
{dropdownItems.map(menu => (
<Menu.Item key={menu.label}>
<a href={menu.url}>
<i
data-test={`menu-item-${menu.label}`}
className={`fa ${menu.icon}`}
/>{' '}
{menu.label}
</a>
</Menu.Item>
))}
</SubMenu>
{navbarRight.show_language_picker && (
<LanguagePicker
locale={navbarRight.locale}
languages={navbarRight.languages}
/>
)}
</Menu>
{navbarRight.documentation_url && (
<StyledAnchor
href={navbarRight.documentation_url}
target="_blank"
rel="noreferrer"
title={t('Documentation')}
>
<i className="fa fa-question" />
&nbsp;
</StyledAnchor>
)}
{navbarRight.bug_report_url && (
<StyledAnchor
href={navbarRight.bug_report_url}
target="_blank"
rel="noreferrer"
title={t('Report a bug')}
>
<i className="fa fa-bug" />
</StyledAnchor>
<SubMenu title="Settings" icon={<Icons.TriangleDown iconSize="xl" />}>
{settings.map((section, index) => [
<Menu.ItemGroup key={`${section.label}`} title={section.label}>
{section.childs?.map(child => {
if (typeof child !== 'string') {
return (
<Menu.Item key={`${child.label}`}>
{isFrontendRoute(child.url) ? (
<Link to={child.url || ''}>{child.label}</Link>
) : (
<a href={child.url}>{child.label}</a>
)}
</Menu.Item>
);
}
return null;
})}
</Menu.ItemGroup>,
index < settings.length - 1 && <Menu.Divider />,
])}
{!navbarRight.user_is_anonymous && [
<Menu.Divider key="user-divider" />,
<Menu.ItemGroup key="user-section" title={t('User')}>
{navbarRight.user_profile_url && (
<Menu.Item key="profile">
<a href={navbarRight.user_profile_url}>{t('Profile')}</a>
</Menu.Item>
)}
{navbarRight.user_info_url && (
<Menu.Item key="info">
<a href={navbarRight.user_info_url}>{t('Info')}</a>
</Menu.Item>
)}
<Menu.Item key="logout">
<a href={navbarRight.user_logout_url}>{t('Logout')}</a>
</Menu.Item>
</Menu.ItemGroup>,
]}
{(navbarRight.version_string ||
navbarRight.version_sha ||
navbarRight.build_number) && [
<Menu.Divider key="version-info-divider" />,
<Menu.ItemGroup key="about-section" title={t('About')}>
<div className="about-section">
{navbarRight.show_watermark && (
<div css={versionInfoStyles}>
{t('Powered by Apache Superset')}
</div>
)}
{navbarRight.version_string && (
<div css={versionInfoStyles}>
Version: {navbarRight.version_string}
</div>
)}
{navbarRight.version_sha && (
<div css={versionInfoStyles}>
SHA: {navbarRight.version_sha}
</div>
)}
{navbarRight.build_number && (
<div css={versionInfoStyles}>
Build: {navbarRight.build_number}
</div>
)}
</div>
</Menu.ItemGroup>,
]}
</SubMenu>
{navbarRight.show_language_picker && (
<LanguagePicker
locale={navbarRight.locale}
languages={navbarRight.languages}
/>
)}
{navbarRight.user_is_anonymous && (
<StyledAnchor href={navbarRight.user_login_url}>
<i className="fa fa-fw fa-sign-in" />
{t('Login')}
</StyledAnchor>
)}
</StyledDiv>
);
};
</Menu>
{navbarRight.documentation_url && (
<StyledAnchor
href={navbarRight.documentation_url}
target="_blank"
rel="noreferrer"
title={t('Documentation')}
>
<i className="fa fa-question" />
&nbsp;
</StyledAnchor>
)}
{navbarRight.bug_report_url && (
<StyledAnchor
href={navbarRight.bug_report_url}
target="_blank"
rel="noreferrer"
title={t('Report a bug')}
>
<i className="fa fa-bug" />
</StyledAnchor>
)}
{navbarRight.user_is_anonymous && (
<StyledAnchor href={navbarRight.user_login_url}>
<i className="fa fa-fw fa-sign-in" />
{t('Login')}
</StyledAnchor>
)}
</StyledDiv>
);
export default RightMenu;

View File

@@ -93,6 +93,7 @@ export interface SelectProps extends PickedSelectProps {
pageSize?: number;
invertSelection?: boolean;
fetchOnlyOnSearch?: boolean;
onError?: (error: string) => void;
}
const StyledContainer = styled.div`
@@ -180,6 +181,7 @@ const Select = ({
mode = 'single',
name,
notFoundContent,
onError,
onChange,
onClear,
optionFilterProps = ['label', 'value'],
@@ -327,11 +329,18 @@ const Select = ({
setSearchedValue('');
};
const onError = (response: Response) =>
getClientErrorObject(response).then(e => {
const { error } = e;
setError(error);
});
const internalOnError = useCallback(
(response: Response) =>
getClientErrorObject(response).then(e => {
const { error } = e;
setError(error);
if (onError) {
onError(error);
}
}),
[onError],
);
const handleData = (data: OptionsType) => {
let mergedData: OptionsType = [];
@@ -386,13 +395,13 @@ const Select = ({
setAllValuesLoaded(true);
}
})
.catch(onError)
.catch(internalOnError)
.finally(() => {
setIsLoading(false);
setIsTyping(false);
});
},
[allValuesLoaded, fetchOnlyOnSearch, options],
[allValuesLoaded, fetchOnlyOnSearch, internalOnError, options],
);
const handleOnSearch = useMemo(

View File

@@ -26,7 +26,12 @@ import TableSelector from '.';
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
const createProps = () => ({
dbId: 1,
database: {
id: 1,
database_name: 'main',
backend: 'sqlite',
allow_multi_schema_metadata_fetch: false,
},
schema: 'test_schema',
handleError: jest.fn(),
});

View File

@@ -27,7 +27,9 @@ import { styled, SupersetClient, t } from '@superset-ui/core';
import { Select } from 'src/components';
import { FormLabel } from 'src/components/Form';
import Icons from 'src/components/Icons';
import DatabaseSelector from 'src/components/DatabaseSelector';
import DatabaseSelector, {
DatabaseObject,
} from 'src/components/DatabaseSelector';
import RefreshLabel from 'src/components/RefreshLabel';
import CertifiedIcon from 'src/components/CertifiedIcon';
import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip';
@@ -76,22 +78,12 @@ const TableLabel = styled.span`
interface TableSelectorProps {
clearable?: boolean;
database?: {
id: number;
database_name: string;
backend: string;
allow_multi_schema_metadata_fetch: boolean;
};
dbId: number;
database?: DatabaseObject;
formMode?: boolean;
getDbList?: (arg0: any) => {};
handleError: (msg: string) => void;
isDatabaseSelectEnabled?: boolean;
onDbChange?: (db: {
id: number;
database_name: string;
backend: string;
}) => void;
onDbChange?: (db: DatabaseObject) => void;
onSchemaChange?: (schema?: string) => void;
onSchemasLoad?: () => void;
onTableChange?: (tableName?: string, schema?: string) => void;
@@ -150,7 +142,6 @@ const TableOption = ({ table }: { table: Table }) => {
const TableSelector: FunctionComponent<TableSelectorProps> = ({
database,
dbId,
formMode = false,
getDbList,
handleError,
@@ -165,7 +156,9 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
sqlLabMode = true,
tableName,
}) => {
const [currentDbId, setCurrentDbId] = useState<number | undefined>(dbId);
const [currentDatabase, setCurrentDatabase] = useState<
DatabaseObject | undefined
>(database);
const [currentSchema, setCurrentSchema] = useState<string | undefined>(
schema,
);
@@ -176,21 +169,30 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
const [tableOptions, setTableOptions] = useState<TableOption[]>([]);
useEffect(() => {
if (currentDbId && currentSchema) {
// reset selections
if (database === undefined) {
setCurrentDatabase(undefined);
setCurrentSchema(undefined);
setCurrentTable(undefined);
}
}, [database]);
useEffect(() => {
if (currentDatabase && currentSchema) {
setLoadingTables(true);
const encodedSchema = encodeURIComponent(currentSchema);
const forceRefresh = refresh !== previousRefresh;
// TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes.
const endpoint = encodeURI(
`/superset/tables/${currentDbId}/${encodedSchema}/undefined/${forceRefresh}/`,
`/superset/tables/${currentDatabase.id}/${encodedSchema}/undefined/${forceRefresh}/`,
);
if (previousRefresh !== refresh) {
setPreviousRefresh(refresh);
}
try {
SupersetClient.get({ endpoint }).then(({ json }) => {
SupersetClient.get({ endpoint })
.then(({ json }) => {
const options: TableOption[] = [];
let currentTable;
json.options.forEach((table: Table) => {
@@ -213,15 +215,17 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
),
);
setCurrentTable(currentTable);
setLoadingTables(false);
})
.catch(e => {
setLoadingTables(false);
handleError(t('There was an error loading the tables'));
});
} finally {
setLoadingTables(false);
}
}
// We are using the refresh state to re-trigger the query
// previousRefresh should be out of dependencies array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDbId, currentSchema, onTablesLoad, refresh]);
}, [currentDatabase, currentSchema, onTablesLoad, refresh]);
function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) {
return (
@@ -239,12 +243,8 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
}
};
const internalDbChange = (db: {
id: number;
database_name: string;
backend: string;
}) => {
setCurrentDbId(db?.id);
const internalDbChange = (db: DatabaseObject) => {
setCurrentDatabase(db);
if (onDbChange) {
onDbChange(db);
}
@@ -261,7 +261,8 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
function renderDatabaseSelector() {
return (
<DatabaseSelector
db={database}
key={currentDatabase?.id}
db={currentDatabase}
formMode={formMode}
getDbList={getDbList}
handleError={handleError}

View File

@@ -18,14 +18,18 @@
*/
import React from 'react';
import moment from 'moment-timezone';
import { render } from 'spec/helpers/testing-library';
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import TimezoneSelector from './index';
describe('TimezoneSelector', () => {
let timezone: string;
let timezone: string | undefined;
const onTimezoneChange = jest.fn(zone => {
timezone = zone;
});
beforeEach(() => {
timezone = undefined;
});
it('renders a TimezoneSelector with a default if undefined', () => {
jest.spyOn(moment.tz, 'guess').mockReturnValue('America/New_York');
render(
@@ -36,6 +40,27 @@ describe('TimezoneSelector', () => {
);
expect(onTimezoneChange).toHaveBeenCalledWith('America/Nassau');
});
it('should properly select values from the offsetsToName map', async () => {
jest.spyOn(moment.tz, 'guess').mockReturnValue('America/New_York');
render(
<TimezoneSelector
onTimezoneChange={onTimezoneChange}
timezone={timezone}
/>,
);
const select = screen.getByRole('combobox', {
name: 'Timezone selector',
});
expect(select).toBeInTheDocument();
userEvent.click(select);
const selection = await screen.findByTitle(
'GMT -10:00 (Hawaii Standard Time)',
);
expect(selection).toBeInTheDocument();
userEvent.click(selection);
expect(selection).toBeVisible();
});
it('renders a TimezoneSelector with the closest value if passed in', async () => {
render(
<TimezoneSelector

View File

@@ -17,12 +17,16 @@
* under the License.
*/
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useCallback } from 'react';
import moment from 'moment-timezone';
import { t } from '@superset-ui/core';
import { Select } from 'src/components';
const DEFAULT_TIMEZONE = 'GMT Standard Time';
const DEFAULT_TIMEZONE = {
name: 'GMT Standard Time',
value: 'Africa/Abidjan', // timezones are deduped by the first alphabetical value
};
const MIN_SELECT_WIDTH = '400px';
const offsetsToName = {
@@ -37,7 +41,7 @@ const offsetsToName = {
'-540-480': ['Alaska Standard Time', 'Alaska Daylight Time'],
'-600-600': ['Hawaii Standard Time', 'Hawaii Daylight Time'],
'60120': ['Central European Time', 'Central European Daylight Time'],
'00': [DEFAULT_TIMEZONE, DEFAULT_TIMEZONE],
'00': [DEFAULT_TIMEZONE.name, DEFAULT_TIMEZONE.name],
'060': ['GMT Standard Time - London', 'British Summer Time'],
};
@@ -96,28 +100,31 @@ const TimezoneSelector = ({ onTimezoneChange, timezone }: TimezoneProps) => {
const prevTimezone = useRef(timezone);
const matchTimezoneToOptions = (timezone: string) =>
TIMEZONE_OPTIONS.find(option => option.offsets === getOffsetKey(timezone))
?.value || DEFAULT_TIMEZONE;
?.value || DEFAULT_TIMEZONE.value;
const updateTimezone = (tz: string) => {
// update the ref to track changes
prevTimezone.current = tz;
// the parent component contains the state for the value
onTimezoneChange(tz);
};
const updateTimezone = useCallback(
(tz: string) => {
// update the ref to track changes
prevTimezone.current = tz;
// the parent component contains the state for the value
onTimezoneChange(tz);
},
[onTimezoneChange],
);
useEffect(() => {
const updatedTz = matchTimezoneToOptions(timezone || moment.tz.guess());
if (prevTimezone.current !== updatedTz) {
updateTimezone(updatedTz);
}
}, [timezone]);
}, [timezone, updateTimezone]);
return (
<Select
ariaLabel={t('Timezone')}
ariaLabel={t('Timezone selector')}
css={{ minWidth: MIN_SELECT_WIDTH }} // smallest size for current values
onChange={onTimezoneChange}
value={timezone || DEFAULT_TIMEZONE}
value={timezone || DEFAULT_TIMEZONE.value}
options={TIMEZONE_OPTIONS}
/>
);

View File

@@ -17,13 +17,32 @@
* under the License.
*/
import { Dispatch } from 'redux';
import { makeApi } from '@superset-ui/core';
import { makeApi, CategoricalColorNamespace } from '@superset-ui/core';
import { isString } from 'lodash';
import { ChartConfiguration, DashboardInfo } from '../reducers/types';
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
// updates partially changed dashboard info
export function dashboardInfoChanged(newInfo: { metadata: any }) {
const { metadata } = newInfo;
const categoricalNamespace = CategoricalColorNamespace.getNamespace(
metadata?.color_namespace,
);
categoricalNamespace.resetColors();
if (metadata?.label_colors) {
const labelColors = metadata.label_colors;
const colorMap = isString(labelColors)
? JSON.parse(labelColors)
: labelColors;
Object.keys(colorMap).forEach(label => {
categoricalNamespace.setColor(label, colorMap[label]);
});
}
return { type: DASHBOARD_INFO_UPDATED, newInfo };
}
export const SET_CHART_CONFIG_BEGIN = 'SET_CHART_CONFIG_BEGIN';

View File

@@ -18,7 +18,7 @@
*/
/* eslint camelcase: 0 */
import { ActionCreators as UndoActionCreators } from 'redux-undo';
import { t, SupersetClient } from '@superset-ui/core';
import { ensureIsArray, t, SupersetClient } from '@superset-ui/core';
import { addChart, removeChart, refreshChart } from 'src/chart/chartAction';
import { chart as initChart } from 'src/chart/chartReducer';
import { applyDefaultFormData } from 'src/explore/store';
@@ -35,13 +35,18 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { safeStringify } from 'src/utils/safeStringify';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout';
import { setChartConfiguration } from './dashboardInfo';
import {
setChartConfiguration,
dashboardInfoChanged,
SET_CHART_CONFIG_COMPLETE,
} from './dashboardInfo';
import { fetchDatasourceMetadata } from './datasources';
import {
addFilter,
removeFilter,
updateDirectPathToFilter,
} from './dashboardFilters';
import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters';
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
export function setUnsavedChanges(hasUnsavedChanges) {
@@ -171,8 +176,6 @@ export function saveDashboardRequestSuccess(lastModifiedTime) {
}
export function saveDashboardRequest(data, id, saveType) {
const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
return (dispatch, getState) => {
dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST });
@@ -189,54 +192,184 @@ export function saveDashboardRequest(data, id, saveType) {
const serializedFilters = serializeActiveFilterValues(getActiveFilters());
// serialize filter scope for each filter field, grouped by filter id
const serializedFilterScopes = serializeFilterScopes(dashboardFilters);
const {
certified_by,
certification_details,
css,
dashboard_title,
owners,
roles,
slug,
} = data;
const hasId = item => item.id !== undefined;
// making sure the data is what the backend expects
const cleanedData = {
...data,
certified_by: certified_by || '',
certification_details:
certified_by && certification_details ? certification_details : '',
css: css || '',
dashboard_title: dashboard_title || t('[ untitled dashboard ]'),
owners: ensureIsArray(owners).map(o => (hasId(o) ? o.id : o)),
roles: !isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)
? undefined
: ensureIsArray(roles).map(r => (hasId(r) ? r.id : r)),
slug: slug || null,
metadata: {
...data.metadata,
color_namespace: data.metadata?.color_namespace || undefined,
color_scheme: data.metadata?.color_scheme || '',
expanded_slices: data.metadata?.expanded_slices || {},
label_colors: data.metadata?.label_colors || {},
refresh_frequency: data.metadata?.refresh_frequency || 0,
timed_refresh_immune_slices:
data.metadata?.timed_refresh_immune_slices || [],
},
};
const handleChartConfiguration = () => {
const {
dashboardInfo: {
metadata: { chart_configuration = {} },
},
} = getState();
const chartConfiguration = Object.values(chart_configuration).reduce(
(prev, next) => {
// If chart removed from dashboard - remove it from metadata
if (
Object.values(layout).find(
layoutItem => layoutItem?.meta?.chartId === next.id,
)
) {
return { ...prev, [next.id]: next };
}
return prev;
},
{},
);
return chartConfiguration;
};
const onCopySuccess = response => {
const lastModifiedTime = response.json.last_modified_time;
if (lastModifiedTime) {
dispatch(saveDashboardRequestSuccess(lastModifiedTime));
}
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
const chartConfiguration = handleChartConfiguration();
dispatch(setChartConfiguration(chartConfiguration));
}
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
return response;
};
const onUpdateSuccess = response => {
const updatedDashboard = response.json.result;
const lastModifiedTime = response.json.last_modified_time;
// synching with the backend transformations of the metadata
if (updatedDashboard.json_metadata) {
const metadata = JSON.parse(updatedDashboard.json_metadata);
dispatch(
dashboardInfoChanged({
metadata,
}),
);
if (metadata.chart_configuration) {
dispatch({
type: SET_CHART_CONFIG_COMPLETE,
chartConfiguration: metadata.chart_configuration,
});
}
if (metadata.native_filter_configuration) {
dispatch({
type: SET_FILTER_CONFIG_COMPLETE,
filterConfig: metadata.native_filter_configuration,
});
}
}
if (lastModifiedTime) {
dispatch(saveDashboardRequestSuccess(lastModifiedTime));
}
// redirect to the new slug or id
window.history.pushState(
{ event: 'dashboard_properties_changed' },
'',
`/superset/dashboard/${slug || id}/`,
);
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
return response;
};
const onError = async response => {
const { error, message } = await getClientErrorObject(response);
let errorText = t('Sorry, an unknown error occured');
if (error) {
errorText = t(
'Sorry, there was an error saving this dashboard: %s',
error,
);
}
if (typeof message === 'string' && message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
dispatch(addDangerToast(errorText));
};
if (saveType === SAVE_TYPE_OVERWRITE) {
let chartConfiguration = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
chartConfiguration = handleChartConfiguration();
}
const updatedDashboard = {
certified_by: cleanedData.certified_by,
certification_details: cleanedData.certification_details,
css: cleanedData.css,
dashboard_title: cleanedData.dashboard_title,
slug: cleanedData.slug,
owners: cleanedData.owners,
roles: cleanedData.roles,
json_metadata: safeStringify({
...(cleanedData?.metadata || {}),
default_filters: safeStringify(serializedFilters),
filter_scopes: serializedFilterScopes,
chart_configuration: chartConfiguration,
}),
};
return SupersetClient.put({
endpoint: `/api/v1/dashboard/${id}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedDashboard),
})
.then(response => onUpdateSuccess(response))
.catch(response => onError(response));
}
// changing the data as the endpoint requires
const copyData = cleanedData;
if (copyData.metadata) {
delete copyData.metadata;
}
const finalCopyData = {
...copyData,
// the endpoint is expecting the metadata to be flat
...(cleanedData?.metadata || {}),
};
return SupersetClient.post({
endpoint: `/superset/${path}/${id}/`,
endpoint: `/superset/copy_dash/${id}/`,
postPayload: {
data: {
...data,
...finalCopyData,
default_filters: safeStringify(serializedFilters),
filter_scopes: safeStringify(serializedFilterScopes),
},
},
})
.then(response => {
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
const {
dashboardInfo: {
metadata: { chart_configuration = {} },
},
} = getState();
const chartConfiguration = Object.values(chart_configuration).reduce(
(prev, next) => {
// If chart removed from dashboard - remove it from metadata
if (
Object.values(layout).find(
layoutItem => layoutItem?.meta?.chartId === next.id,
)
) {
return { ...prev, [next.id]: next };
}
return prev;
},
{},
);
dispatch(setChartConfiguration(chartConfiguration));
}
dispatch(saveDashboardRequestSuccess(response.json.last_modified_time));
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
return response;
})
.catch(response =>
getClientErrorObject(response).then(({ error }) =>
dispatch(
addDangerToast(
`${t(
'Sorry, there was an error saving this dashboard: ',
)} ${error}`,
),
),
),
);
.then(response => onCopySuccess(response))
.catch(response => onError(response));
};
}
@@ -370,8 +503,8 @@ export function setDirectPathToChild(path) {
}
export const SET_ACTIVE_TABS = 'SET_ACTIVE_TABS';
export function setActiveTabs(tabIds) {
return { type: SET_ACTIVE_TABS, tabIds };
export function setActiveTabs(tabId, prevTabId) {
return { type: SET_ACTIVE_TABS, tabId, prevTabId };
}
export const SET_FOCUSED_FILTER_FIELD = 'SET_FOCUSED_FILTER_FIELD';

View File

@@ -88,16 +88,16 @@ export const hydrateDashboard = (dashboardData, chartData) => (
// Priming the color palette with user's label-color mapping provided in
// the dashboard's JSON metadata
if (metadata?.label_colors) {
const scheme = metadata.color_scheme;
const namespace = metadata.color_namespace;
const colorMap = isString(metadata.label_colors)
? JSON.parse(metadata.label_colors)
: metadata.label_colors;
const categoricalNamespace = CategoricalColorNamespace.getNamespace(
namespace,
);
Object.keys(colorMap).forEach(label => {
CategoricalColorNamespace.getScale(scheme, namespace).setColor(
label,
colorMap[label],
);
categoricalNamespace.setColor(label, colorMap[label]);
});
}

View File

@@ -146,6 +146,25 @@ export const setInScopeStatusOfFilters = (
type: SET_IN_SCOPE_STATUS_OF_FILTERS,
filterConfig: filtersWithScopes,
});
// need to update native_filter_configuration in the dashboard metadata
const { metadata } = getState().dashboardInfo;
const filterConfig: FilterConfiguration =
metadata.native_filter_configuration;
const mergedFilterConfig = filterConfig.map(filter => {
const filterWithScope = filtersWithScopes.find(
scope => scope.id === filter.id,
);
if (!filterWithScope) {
return filter;
}
return { ...filterWithScope, ...filter };
});
metadata.native_filter_configuration = mergedFilterConfig;
dispatch(
dashboardInfoChanged({
metadata,
}),
);
};
type BootstrapData = {

View File

@@ -19,12 +19,12 @@
import React from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { CssEditor as AceCssEditor } from 'src/components/AsyncAceEditor';
import { AceEditorProps } from 'react-ace';
import { IAceEditorProps } from 'react-ace';
import userEvent from '@testing-library/user-event';
import CssEditor from '.';
jest.mock('src/components/AsyncAceEditor', () => ({
CssEditor: ({ value, onChange }: AceEditorProps) => (
CssEditor: ({ value, onChange }: IAceEditorProps) => (
<textarea
defaultValue={value}
onChange={value => onChange?.(value.target.value)}

View File

@@ -117,11 +117,12 @@ const REPORT_ENDPOINT = 'glob:*/api/v1/report*';
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
fetchMock.get(REPORT_ENDPOINT, {});
function setup(props: HeaderProps) {
return (
function setup(props: HeaderProps, initialState = {}) {
return render(
<div className="dashboard">
<Header {...props} />
</div>
</div>,
{ useRedux: true, initialState },
);
}
@@ -133,23 +134,23 @@ async function openActionsDropdown() {
test('should render', () => {
const mockedProps = createProps();
const { container } = render(setup(mockedProps));
const { container } = setup(mockedProps);
expect(container).toBeInTheDocument();
});
test('should render the title', () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(screen.getByText('Dashboard Title')).toBeInTheDocument();
});
test('should render the editable title', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByDisplayValue('Dashboard Title')).toBeInTheDocument();
});
test('should edit the title', () => {
render(setup(editableProps));
setup(editableProps);
const editableTitle = screen.getByDisplayValue('Dashboard Title');
expect(editableProps.onChange).not.toHaveBeenCalled();
userEvent.click(editableTitle);
@@ -162,12 +163,12 @@ test('should edit the title', () => {
test('should render the "Draft" status', () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(screen.getByText('Draft')).toBeInTheDocument();
});
test('should publish', () => {
render(setup(editableProps));
setup(editableProps);
const draft = screen.getByText('Draft');
expect(editableProps.savePublished).not.toHaveBeenCalled();
userEvent.click(draft);
@@ -175,12 +176,12 @@ test('should publish', () => {
});
test('should render the "Undo" action as disabled', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByTitle('Undo').parentElement).toBeDisabled();
});
test('should undo', () => {
render(setup(undoProps));
setup(undoProps);
const undo = screen.getByTitle('Undo');
expect(undoProps.onUndo).not.toHaveBeenCalled();
userEvent.click(undo);
@@ -189,19 +190,19 @@ test('should undo', () => {
test('should undo with key listener', () => {
undoProps.onUndo.mockReset();
render(setup(undoProps));
setup(undoProps);
expect(undoProps.onUndo).not.toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: 'z', code: 'KeyZ', ctrlKey: true });
expect(undoProps.onUndo).toHaveBeenCalledTimes(1);
});
test('should render the "Redo" action as disabled', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByTitle('Redo').parentElement).toBeDisabled();
});
test('should redo', () => {
render(setup(redoProps));
setup(redoProps);
const redo = screen.getByTitle('Redo');
expect(redoProps.onRedo).not.toHaveBeenCalled();
userEvent.click(redo);
@@ -210,19 +211,19 @@ test('should redo', () => {
test('should redo with key listener', () => {
redoProps.onRedo.mockReset();
render(setup(redoProps));
setup(redoProps);
expect(redoProps.onRedo).not.toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: 'y', code: 'KeyY', ctrlKey: true });
expect(redoProps.onRedo).toHaveBeenCalledTimes(1);
});
test('should render the "Discard changes" button', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByText('Discard changes')).toBeInTheDocument();
});
test('should render the "Save" button as disabled', () => {
render(setup(editableProps));
setup(editableProps);
expect(screen.getByText('Save').parentElement).toBeDisabled();
});
@@ -231,7 +232,7 @@ test('should save', () => {
...editableProps,
hasUnsavedChanges: true,
};
render(setup(unsavedProps));
setup(unsavedProps);
const save = screen.getByText('Save');
expect(unsavedProps.onSave).not.toHaveBeenCalled();
userEvent.click(save);
@@ -244,13 +245,13 @@ test('should NOT render the "Draft" status', () => {
...mockedProps,
isPublished: true,
};
render(setup(publishedProps));
setup(publishedProps);
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
});
test('should render the unselected fave icon', () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(mockedProps.fetchFaveStar).toHaveBeenCalled();
expect(
screen.getByRole('img', { name: 'favorite-unselected' }),
@@ -263,7 +264,7 @@ test('should render the selected fave icon', () => {
...mockedProps,
isStarred: true,
};
render(setup(favedProps));
setup(favedProps);
expect(
screen.getByRole('img', { name: 'favorite-selected' }),
).toBeInTheDocument();
@@ -275,7 +276,7 @@ test('should NOT render the fave icon on anonymous user', () => {
...mockedProps,
user: undefined,
};
render(setup(anonymousUserProps));
setup(anonymousUserProps);
expect(() =>
screen.getByRole('img', { name: 'favorite-unselected' }),
).toThrowError('Unable to find');
@@ -286,7 +287,7 @@ test('should NOT render the fave icon on anonymous user', () => {
test('should fave', async () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
const fave = screen.getByRole('img', { name: 'favorite-unselected' });
expect(mockedProps.saveFaveStar).not.toHaveBeenCalled();
userEvent.click(fave);
@@ -302,7 +303,7 @@ test('should toggle the edit mode', () => {
dash_edit_perm: true,
},
};
render(setup(canEditProps));
setup(canEditProps);
const editDashboard = screen.getByTitle('Edit dashboard');
expect(screen.queryByTitle('Edit dashboard')).toBeInTheDocument();
userEvent.click(editDashboard);
@@ -311,13 +312,13 @@ test('should toggle the edit mode', () => {
test('should render the dropdown icon', () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
expect(screen.getByRole('img', { name: 'more-horiz' })).toBeInTheDocument();
});
test('should refresh the charts', async () => {
const mockedProps = createProps();
render(setup(mockedProps));
setup(mockedProps);
await openActionsDropdown();
userEvent.click(screen.getByText('Refresh dashboard'));
expect(mockedProps.onRefresh).toHaveBeenCalledTimes(1);
@@ -341,7 +342,7 @@ describe('Email Report Modal', () => {
it('creates a new email report', async () => {
// ---------- Render/value setup ----------
const mockedProps = createProps();
render(setup(mockedProps), { useRedux: true });
setup(mockedProps);
const reportValues = {
id: 1,
@@ -423,10 +424,7 @@ describe('Email Report Modal', () => {
};
// getMockStore({ reports: reportValues });
render(setup(mockedProps), {
useRedux: true,
initialState: mockState,
});
setup(mockedProps, mockState);
// TODO (lyndsiWilliams): currently fetchMock detects this PUT
// address as 'glob:*/api/v1/report/undefined', is not detected
// on fetchMock.calls()
@@ -465,4 +463,31 @@ describe('Email Report Modal', () => {
// BLOCKER: I cannot get report to populate, as its data is handled through redux
expect.anything();
});
it('Should render report header', async () => {
const mockedProps = createProps();
setup(mockedProps);
expect(
screen.getByRole('button', { name: 'Schedule email report' }),
).toBeInTheDocument();
});
it('Should not render report header even with menu access for anonymous user', async () => {
const mockedProps = createProps();
const anonymousUserProps = {
...mockedProps,
user: {
roles: {
Public: [['menu_access', 'Manage']],
},
permissions: {
datasource_access: ['[examples].[birth_names](id:2)'],
},
},
};
setup(anonymousUserProps);
expect(
screen.queryByRole('button', { name: 'Schedule email report' }),
).not.toBeInTheDocument();
});
});

View File

@@ -20,8 +20,9 @@
import moment from 'moment';
import React from 'react';
import PropTypes from 'prop-types';
import { styled, CategoricalColorNamespace, t } from '@superset-ui/core';
import { styled, t } from '@superset-ui/core';
import ButtonGroup from 'src/components/ButtonGroup';
import CertifiedIcon from 'src/components/CertifiedIcon';
import {
LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD,
@@ -323,45 +324,38 @@ class Header extends React.PureComponent {
const {
dashboardTitle,
layout: positions,
expandedSlices,
customCss,
colorNamespace,
colorScheme,
colorNamespace,
customCss,
dashboardInfo,
refreshFrequency: currentRefreshFrequency,
shouldPersistRefreshFrequency,
lastModifiedTime,
slug,
} = this.props;
const scale = CategoricalColorNamespace.getScale(
colorScheme,
colorNamespace,
);
// use the colorScheme for default labels
let labelColors = colorScheme ? scale.getColorMap() : {};
// but allow metadata to overwrite if it exists
// eslint-disable-next-line camelcase
const metadataLabelColors = dashboardInfo.metadata?.label_colors;
if (metadataLabelColors) {
labelColors = { ...labelColors, ...metadataLabelColors };
}
// check refresh frequency is for current session or persist
const refreshFrequency = shouldPersistRefreshFrequency
? currentRefreshFrequency
: dashboardInfo.metadata?.refresh_frequency; // eslint-disable-line camelcase
: dashboardInfo.metadata?.refresh_frequency;
const data = {
positions,
expanded_slices: expandedSlices,
certified_by: dashboardInfo.certified_by,
certification_details: dashboardInfo.certification_details,
css: customCss,
color_namespace: colorNamespace,
color_scheme: colorScheme,
label_colors: labelColors,
dashboard_title: dashboardTitle,
refresh_frequency: refreshFrequency,
last_modified_time: lastModifiedTime,
owners: dashboardInfo.owners,
roles: dashboardInfo.roles,
slug,
metadata: {
...dashboardInfo?.metadata,
color_namespace:
dashboardInfo?.metadata?.color_namespace || colorNamespace,
color_scheme: dashboardInfo?.metadata?.color_scheme || colorScheme,
positions,
refresh_frequency: refreshFrequency,
},
};
// make sure positions data less than DB storage limitation:
@@ -479,6 +473,20 @@ class Header extends React.PureComponent {
dashboardInfo.common.conf
.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_WARNING_MESSAGE;
const handleOnPropertiesChange = updates => {
const { dashboardInfoChanged, dashboardTitleChanged } = this.props;
dashboardInfoChanged({
slug: updates.slug,
metadata: JSON.parse(updates.jsonMetadata || '{}'),
certified_by: updates.certifiedBy,
certification_details: updates.certificationDetails,
owners: updates.owners,
roles: updates.roles,
});
setColorSchemeAndUnsavedChanges(updates.colorScheme);
dashboardTitleChanged(updates.title);
};
return (
<StyledDashboardHeader
className="dashboard-header"
@@ -486,6 +494,14 @@ class Header extends React.PureComponent {
data-test-id={`${dashboardInfo.id}`}
>
<div className="dashboard-component-header header-large">
{dashboardInfo.certified_by && (
<>
<CertifiedIcon
certifiedBy={dashboardInfo.certified_by}
details={dashboardInfo.certification_details}
/>{' '}
</>
)}
<EditableTitle
title={dashboardTitle}
canEdit={userCanEdit && editMode}
@@ -592,33 +608,16 @@ class Header extends React.PureComponent {
)}
{shouldShowReport && this.renderReportModal()}
{this.state.showingPropertiesModal && (
<PropertiesModal
dashboardId={dashboardInfo.id}
show={this.state.showingPropertiesModal}
onHide={this.hidePropertiesModal}
colorScheme={this.props.colorScheme}
onSubmit={updates => {
const {
dashboardInfoChanged,
dashboardTitleChanged,
} = this.props;
dashboardInfoChanged({
slug: updates.slug,
metadata: JSON.parse(updates.jsonMetadata),
});
setColorSchemeAndUnsavedChanges(updates.colorScheme);
dashboardTitleChanged(updates.title);
if (updates.slug) {
window.history.pushState(
{ event: 'dashboard_properties_changed' },
'',
`/superset/dashboard/${updates.slug}/`,
);
}
}}
/>
)}
<PropertiesModal
dashboardId={dashboardInfo.id}
dashboardInfo={dashboardInfo}
dashboardTitle={dashboardTitle}
show={this.state.showingPropertiesModal}
onHide={this.hidePropertiesModal}
colorScheme={this.props.colorScheme}
onSubmit={handleOnPropertiesChange}
onlyApply
/>
{this.state.showingReportModal && (
<ReportModal

File diff suppressed because one or more lines are too long

View File

@@ -1,546 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { Row, Col, Input } from 'src/common/components';
import { Form, FormItem } from 'src/components/Form';
import jsonStringify from 'json-stringify-pretty-compact';
import Button from 'src/components/Button';
import { Select } from 'src/components';
import rison from 'rison';
import {
styled,
t,
SupersetClient,
getCategoricalSchemeRegistry,
CategoricalColorNamespace,
} from '@superset-ui/core';
import Modal from 'src/components/Modal';
import { JsonEditor } from 'src/components/AsyncAceEditor';
import ColorSchemeControlWrapper from 'src/dashboard/components/ColorSchemeControlWrapper';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import withToasts from 'src/components/MessageToasts/withToasts';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
const StyledJsonEditor = styled(JsonEditor)`
border-radius: ${({ theme }) => theme.borderRadius}px;
border: 1px solid ${({ theme }) => theme.colors.secondary.light2};
`;
const propTypes = {
dashboardId: PropTypes.number.isRequired,
show: PropTypes.bool,
onHide: PropTypes.func,
colorScheme: PropTypes.string,
setColorSchemeAndUnsavedChanges: PropTypes.func,
onSubmit: PropTypes.func,
addSuccessToast: PropTypes.func.isRequired,
onlyApply: PropTypes.bool,
};
const defaultProps = {
onHide: () => {},
setColorSchemeAndUnsavedChanges: () => {},
onSubmit: () => {},
show: false,
colorScheme: undefined,
onlyApply: false,
};
const handleErrorResponse = async response => {
const { error, statusText, message } = await getClientErrorObject(response);
let errorText = error || statusText || t('An error has occurred');
if (typeof message === 'object' && message.json_metadata) {
errorText = message.json_metadata;
} else if (typeof message === 'string') {
errorText = message;
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
}
Modal.error({
title: 'Error',
content: errorText,
okButtonProps: { danger: true, className: 'btn-danger' },
});
};
const loadAccessOptions = accessType => (input = '') => {
const query = rison.encode({ filter: input });
return SupersetClient.get({
endpoint: `/api/v1/dashboard/related/${accessType}?q=${query}`,
}).then(
response => ({
data: response.json.result.map(item => ({
value: item.value,
label: item.text,
})),
totalCount: response.json.count,
}),
badResponse => {
handleErrorResponse(badResponse);
return [];
},
);
};
const loadOwners = loadAccessOptions('owners');
const loadRoles = loadAccessOptions('roles');
class PropertiesModal extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
errors: [],
values: {
dashboard_title: '',
slug: '',
owners: [],
roles: [],
json_metadata: '',
colorScheme: props.colorScheme,
},
isDashboardLoaded: false,
isAdvancedOpen: false,
};
this.onChange = this.onChange.bind(this);
this.onMetadataChange = this.onMetadataChange.bind(this);
this.onOwnersChange = this.onOwnersChange.bind(this);
this.onRolesChange = this.onRolesChange.bind(this);
this.submit = this.submit.bind(this);
this.toggleAdvanced = this.toggleAdvanced.bind(this);
this.onColorSchemeChange = this.onColorSchemeChange.bind(this);
this.getRowsWithRoles = this.getRowsWithRoles.bind(this);
this.getRowsWithoutRoles = this.getRowsWithoutRoles.bind(this);
}
componentDidMount() {
this.fetchDashboardDetails();
JsonEditor.preload();
}
onColorSchemeChange(value, { updateMetadata = true } = {}) {
// check that color_scheme is valid
const colorChoices = getCategoricalSchemeRegistry().keys();
const { json_metadata: jsonMetadata } = this.state.values;
const jsonMetadataObj = jsonMetadata?.length
? JSON.parse(jsonMetadata)
: {};
if (!colorChoices.includes(value)) {
Modal.error({
title: 'Error',
content: t('A valid color scheme is required'),
okButtonProps: { danger: true, className: 'btn-danger' },
});
throw new Error('A valid color scheme is required');
}
// update metadata to match selection
if (
updateMetadata &&
Object.keys(jsonMetadataObj).includes('color_scheme')
) {
jsonMetadataObj.color_scheme = value;
jsonMetadataObj.label_colors = Object.keys(
jsonMetadataObj.label_colors ?? {},
).reduce(
(prev, next) => ({
...prev,
[next]: CategoricalColorNamespace.getScale(value)(next),
}),
{},
);
this.onMetadataChange(jsonStringify(jsonMetadataObj));
}
this.updateFormState('colorScheme', value);
}
onOwnersChange(value) {
this.updateFormState('owners', value);
}
onRolesChange(value) {
this.updateFormState('roles', value);
}
onMetadataChange(metadata) {
this.updateFormState('json_metadata', metadata);
}
onChange(e) {
const { name, value } = e.target;
this.updateFormState(name, value);
}
fetchDashboardDetails() {
// We fetch the dashboard details because not all code
// that renders this component have all the values we need.
// At some point when we have a more consistent frontend
// datamodel, the dashboard could probably just be passed as a prop.
SupersetClient.get({
endpoint: `/api/v1/dashboard/${this.props.dashboardId}`,
}).then(response => {
const dashboard = response.json.result;
const jsonMetadataObj = dashboard.json_metadata?.length
? JSON.parse(dashboard.json_metadata)
: {};
this.setState(state => ({
isDashboardLoaded: true,
values: {
...state.values,
dashboard_title: dashboard.dashboard_title || '',
slug: dashboard.slug || '',
// format json with 2-space indentation
json_metadata: dashboard.json_metadata
? jsonStringify(jsonMetadataObj)
: '',
colorScheme: jsonMetadataObj.color_scheme,
},
}));
const initialSelectedOwners = dashboard.owners.map(owner => ({
value: owner.id,
label: `${owner.first_name} ${owner.last_name}`,
}));
const initialSelectedRoles = dashboard.roles.map(role => ({
value: role.id,
label: `${role.name}`,
}));
this.onOwnersChange(initialSelectedOwners);
this.onRolesChange(initialSelectedRoles);
}, handleErrorResponse);
}
updateFormState(name, value) {
this.setState(state => ({
values: {
...state.values,
[name]: value,
},
}));
}
toggleAdvanced() {
this.setState(state => ({
isAdvancedOpen: !state.isAdvancedOpen,
}));
}
submit(e) {
e.preventDefault();
e.stopPropagation();
const {
values: {
json_metadata: jsonMetadata,
slug,
dashboard_title: dashboardTitle,
colorScheme,
owners: ownersValue,
roles: rolesValue,
},
} = this.state;
const { onlyApply } = this.props;
const owners = ownersValue?.map(o => o.value) ?? [];
const roles = rolesValue?.map(o => o.value) ?? [];
let metadataColorScheme;
// update color scheme to match metadata
if (jsonMetadata?.length) {
const { color_scheme: metadataColorScheme } = JSON.parse(jsonMetadata);
if (metadataColorScheme) {
this.onColorSchemeChange(metadataColorScheme, {
updateMetadata: false,
});
}
}
const moreProps = {};
const morePutProps = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
moreProps.rolesIds = roles;
morePutProps.roles = roles;
}
if (onlyApply) {
this.props.onSubmit({
id: this.props.dashboardId,
title: dashboardTitle,
slug,
jsonMetadata,
ownerIds: owners,
colorScheme: metadataColorScheme || colorScheme,
...moreProps,
});
this.props.onHide();
} else {
SupersetClient.put({
endpoint: `/api/v1/dashboard/${this.props.dashboardId}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dashboard_title: dashboardTitle,
slug: slug || null,
json_metadata: jsonMetadata || null,
owners,
...morePutProps,
}),
}).then(({ json: { result } }) => {
const moreResultProps = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
moreResultProps.rolesIds = result.roles;
}
this.props.addSuccessToast(t('The dashboard has been saved'));
this.props.onSubmit({
id: this.props.dashboardId,
title: result.dashboard_title,
slug: result.slug,
jsonMetadata: result.json_metadata,
ownerIds: result.owners,
colorScheme: metadataColorScheme || colorScheme,
...moreResultProps,
});
this.props.onHide();
}, handleErrorResponse);
}
}
getRowsWithoutRoles() {
const { values, isDashboardLoaded } = this.state;
return (
<Row gutter={16}>
<Col xs={24} md={12}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
<FormItem label={t('Owners')}>
<Select
allowClear
ariaLabel={t('Owners')}
disabled={!isDashboardLoaded}
name="owners"
mode="multiple"
value={values.owners}
options={loadOwners}
onChange={this.onOwnersChange}
/>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
)}
</p>
</FormItem>
</Col>
<Col xs={24} md={12}>
<h3 style={{ marginTop: '1em' }}>{t('Colors')}</h3>
<ColorSchemeControlWrapper
onChange={this.onColorSchemeChange}
colorScheme={values.colorScheme}
labelMargin={4}
/>
</Col>
</Row>
);
}
getRowsWithRoles() {
const { values, isDashboardLoaded } = this.state;
return (
<>
<Row>
<Col xs={24} md={24}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormItem label={t('Owners')}>
<Select
allowClear
ariaLabel={t('Owners')}
disabled={!isDashboardLoaded}
name="owners"
mode="multiple"
value={values.owners}
options={loadOwners}
onChange={this.onOwnersChange}
/>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
)}
</p>
</FormItem>
</Col>
<Col xs={24} md={12}>
<FormItem label={t('Roles')}>
<Select
allowClear
ariaLabel={t('Roles')}
disabled={!isDashboardLoaded}
name="roles"
mode="multiple"
value={values.roles}
options={loadRoles}
onChange={this.onRolesChange}
/>
<p className="help-block">
{t(
'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles defined then the dashboard is available to all roles.',
)}
</p>
</FormItem>
</Col>
</Row>
<Row>
<Col xs={24} md={12}>
<ColorSchemeControlWrapper
onChange={this.onColorSchemeChange}
colorScheme={values.colorScheme}
labelMargin={4}
/>
</Col>
</Row>
</>
);
}
render() {
const { values, isDashboardLoaded, isAdvancedOpen, errors } = this.state;
const { onHide, onlyApply } = this.props;
const saveLabel = onlyApply ? t('Apply') : t('Save');
return (
<Modal
show={this.props.show}
onHide={this.props.onHide}
title={t('Dashboard properties')}
footer={
<>
<Button
htmlType="button"
buttonSize="small"
onClick={onHide}
data-test="properties-modal-cancel-button"
cta
>
{t('Cancel')}
</Button>
<Button
onClick={this.submit}
buttonSize="small"
buttonStyle="primary"
className="m-r-5"
disabled={errors.length > 0}
cta
>
{saveLabel}
</Button>
</>
}
responsive
>
<Form
data-test="dashboard-edit-properties-form"
onSubmit={this.submit}
layout="vertical"
>
<Row>
<Col xs={24} md={24}>
<h3>{t('Basic information')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormItem label={t('Title')}>
<Input
data-test="dashboard-title-input"
name="dashboard_title"
type="text"
value={values.dashboard_title}
onChange={this.onChange}
disabled={!isDashboardLoaded}
/>
</FormItem>
</Col>
<Col xs={24} md={12}>
<FormItem label={t('URL slug')}>
<Input
name="slug"
type="text"
value={values.slug || ''}
onChange={this.onChange}
disabled={!isDashboardLoaded}
/>
<p className="help-block">
{t('A readable URL for your dashboard')}
</p>
</FormItem>
</Col>
</Row>
{isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)
? this.getRowsWithRoles()
: this.getRowsWithoutRoles()}
<Row>
<Col xs={24} md={24}>
<h3 style={{ marginTop: '1em' }}>
<Button buttonStyle="link" onClick={this.toggleAdvanced}>
<i
className={`fa fa-angle-${
isAdvancedOpen ? 'down' : 'right'
}`}
style={{ minWidth: '1em' }}
/>
{t('Advanced')}
</Button>
</h3>
{isAdvancedOpen && (
<FormItem label={t('JSON metadata')}>
<StyledJsonEditor
showLoadingForImport
name="json_metadata"
defaultValue={this.defaultMetadataValue}
value={values.json_metadata}
onChange={this.onMetadataChange}
tabSize={2}
width="100%"
height="200px"
wrapEnabled
/>
<p className="help-block">
{t(
'This JSON object is generated dynamically when clicking the save or overwrite button in the dashboard view. It is exposed here for reference and for power users who may want to alter specific parameters.',
)}
</p>
</FormItem>
)}
</Col>
</Row>
</Form>
</Modal>
);
}
}
PropertiesModal.propTypes = propTypes;
PropertiesModal.defaultProps = defaultProps;
export default withToasts(PropertiesModal);

View File

@@ -0,0 +1,612 @@
/**
* 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, useState } from 'react';
import { Form, Row, Col, Input } from 'src/common/components';
import { FormItem } from 'src/components/Form';
import jsonStringify from 'json-stringify-pretty-compact';
import Button from 'src/components/Button';
import { Select } from 'src/components';
import rison from 'rison';
import {
styled,
t,
SupersetClient,
getCategoricalSchemeRegistry,
ensureIsArray,
} from '@superset-ui/core';
import Modal from 'src/components/Modal';
import { JsonEditor } from 'src/components/AsyncAceEditor';
import ColorSchemeControlWrapper from 'src/dashboard/components/ColorSchemeControlWrapper';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import withToasts from 'src/components/MessageToasts/withToasts';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
const StyledFormItem = styled(FormItem)`
margin-bottom: 0;
`;
const StyledJsonEditor = styled(JsonEditor)`
border-radius: ${({ theme }) => theme.borderRadius}px;
border: 1px solid ${({ theme }) => theme.colors.secondary.light2};
`;
type PropertiesModalProps = {
dashboardId: number;
dashboardTitle?: string;
dashboardInfo?: Record<string, any>;
show?: boolean;
onHide?: () => void;
colorScheme?: string;
setColorSchemeAndUnsavedChanges?: () => void;
onSubmit?: (params: Record<string, any>) => void;
addSuccessToast: (message: string) => void;
onlyApply?: boolean;
};
type Roles = { id: number; name: string }[];
type Owners = {
id: number;
full_name?: string;
first_name?: string;
last_name?: string;
}[];
type DashboardInfo = {
id: number;
title: string;
slug: string;
certifiedBy: string;
certificationDetails: string;
};
const PropertiesModal = ({
addSuccessToast,
colorScheme: currentColorScheme,
dashboardId,
dashboardInfo: currentDashboardInfo,
dashboardTitle,
onHide = () => {},
onlyApply = false,
onSubmit = () => {},
show = false,
}: PropertiesModalProps) => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [colorScheme, setColorScheme] = useState(currentColorScheme);
const [jsonMetadata, setJsonMetadata] = useState('');
const [dashboardInfo, setDashboardInfo] = useState<DashboardInfo>();
const [owners, setOwners] = useState<Owners>([]);
const [roles, setRoles] = useState<Roles>([]);
const saveLabel = onlyApply ? t('Apply') : t('Save');
const handleErrorResponse = async (response: Response) => {
const { error, statusText, message } = await getClientErrorObject(response);
let errorText = error || statusText || t('An error has occurred');
if (typeof message === 'object' && 'json_metadata' in message) {
errorText = (message as { json_metadata: string }).json_metadata;
} else if (typeof message === 'string') {
errorText = message;
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
}
Modal.error({
title: 'Error',
content: errorText,
okButtonProps: { danger: true, className: 'btn-danger' },
});
};
const loadAccessOptions = useCallback(
(accessType = 'owners', input = '', page: number, pageSize: number) => {
const query = rison.encode({
filter: input,
page,
page_size: pageSize,
});
return SupersetClient.get({
endpoint: `/api/v1/dashboard/related/${accessType}?q=${query}`,
}).then(response => ({
data: response.json.result.map(
(item: { value: number; text: string }) => ({
value: item.value,
label: item.text,
}),
),
totalCount: response.json.count,
}));
},
[],
);
const handleDashboardData = useCallback(
dashboardData => {
const {
id,
dashboard_title,
slug,
certified_by,
certification_details,
owners,
roles,
metadata,
} = dashboardData;
const dashboardInfo = {
id,
title: dashboard_title,
slug: slug || '',
certifiedBy: certified_by || '',
certificationDetails: certification_details || '',
};
form.setFieldsValue(dashboardInfo);
setDashboardInfo(dashboardInfo);
setOwners(owners);
setRoles(roles);
setColorScheme(metadata.color_scheme);
// temporary fix to remove positions from dashboards' metadata
if (metadata?.positions) {
delete metadata.positions;
}
setJsonMetadata(metadata ? jsonStringify(metadata) : '');
},
[form],
);
const fetchDashboardDetails = useCallback(() => {
setIsLoading(true);
// We fetch the dashboard details because not all code
// that renders this component have all the values we need.
// At some point when we have a more consistent frontend
// datamodel, the dashboard could probably just be passed as a prop.
SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardId}`,
}).then(response => {
const dashboard = response.json.result;
const jsonMetadataObj = dashboard.json_metadata?.length
? JSON.parse(dashboard.json_metadata)
: {};
handleDashboardData({
...dashboard,
metadata: jsonMetadataObj,
});
setIsLoading(false);
}, handleErrorResponse);
}, [dashboardId, handleDashboardData]);
const getJsonMetadata = () => {
try {
const jsonMetadataObj = jsonMetadata?.length
? JSON.parse(jsonMetadata)
: {};
return jsonMetadataObj;
} catch (_) {
return {};
}
};
const handleOnChangeOwners = (owners: { value: number; label: string }[]) => {
const parsedOwners: Owners = ensureIsArray(owners).map(o => ({
id: o.value,
full_name: o.label,
}));
setOwners(parsedOwners);
};
const handleOnChangeRoles = (roles: { value: number; label: string }[]) => {
const parsedRoles: Roles = ensureIsArray(roles).map(r => ({
id: r.value,
name: r.label,
}));
setRoles(parsedRoles);
};
const handleOwnersSelectValue = () => {
const parsedOwners = (owners || []).map(
(owner: {
id: number;
first_name?: string;
last_name?: string;
full_name?: string;
}) => ({
value: owner.id,
label: owner.full_name || `${owner.first_name} ${owner.last_name}`,
}),
);
return parsedOwners;
};
const handleRolesSelectValue = () => {
const parsedRoles = (roles || []).map(
(role: { id: number; name: string }) => ({
value: role.id,
label: `${role.name}`,
}),
);
return parsedRoles;
};
const onColorSchemeChange = (
colorScheme?: string,
{ updateMetadata = true } = {},
) => {
// check that color_scheme is valid
const colorChoices = getCategoricalSchemeRegistry().keys();
const jsonMetadataObj = getJsonMetadata();
// only fire if the color_scheme is present and invalid
if (colorScheme && !colorChoices.includes(colorScheme)) {
Modal.error({
title: 'Error',
content: t('A valid color scheme is required'),
okButtonProps: { danger: true, className: 'btn-danger' },
});
throw new Error('A valid color scheme is required');
}
// update metadata to match selection
if (updateMetadata) {
jsonMetadataObj.color_scheme = colorScheme;
jsonMetadataObj.label_colors = jsonMetadataObj.label_colors || {};
setJsonMetadata(jsonStringify(jsonMetadataObj));
}
setColorScheme(colorScheme);
};
const onFinish = () => {
const {
title,
slug,
certifiedBy,
certificationDetails,
} = form.getFieldsValue();
let currentColorScheme = colorScheme;
let colorNamespace = '';
// color scheme in json metadata has precedence over selection
if (jsonMetadata?.length) {
const metadata = JSON.parse(jsonMetadata);
currentColorScheme = metadata?.color_scheme || colorScheme;
colorNamespace = metadata?.color_namespace || '';
}
onColorSchemeChange(currentColorScheme, {
updateMetadata: false,
});
const moreOnSubmitProps: { roles?: Roles } = {};
const morePutProps: { roles?: number[] } = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
moreOnSubmitProps.roles = roles;
morePutProps.roles = (roles || []).map(r => r.id);
}
const onSubmitProps = {
id: dashboardId,
title,
slug,
jsonMetadata,
owners,
colorScheme: currentColorScheme,
colorNamespace,
certifiedBy,
certificationDetails,
...moreOnSubmitProps,
};
if (onlyApply) {
onSubmit(onSubmitProps);
onHide();
} else {
SupersetClient.put({
endpoint: `/api/v1/dashboard/${dashboardId}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dashboard_title: title,
slug: slug || null,
json_metadata: jsonMetadata || null,
owners: (owners || []).map(o => o.id),
certified_by: certifiedBy || null,
certification_details:
certifiedBy && certificationDetails ? certificationDetails : null,
...morePutProps,
}),
}).then(() => {
addSuccessToast(t('The dashboard has been saved'));
onSubmit(onSubmitProps);
onHide();
}, handleErrorResponse);
}
};
const getRowsWithoutRoles = () => {
const jsonMetadataObj = getJsonMetadata();
const hasCustomLabelColors = !!Object.keys(
jsonMetadataObj?.label_colors || {},
).length;
return (
<Row gutter={16}>
<Col xs={24} md={12}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
<StyledFormItem label={t('Owners')}>
<Select
allowClear
ariaLabel={t('Owners')}
disabled={isLoading}
mode="multiple"
onChange={handleOnChangeOwners}
options={(input, page, pageSize) =>
loadAccessOptions('owners', input, page, pageSize)
}
value={handleOwnersSelectValue()}
/>
</StyledFormItem>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
)}
</p>
</Col>
<Col xs={24} md={12}>
<h3 style={{ marginTop: '1em' }}>{t('Colors')}</h3>
<ColorSchemeControlWrapper
hasCustomLabelColors={hasCustomLabelColors}
onChange={onColorSchemeChange}
colorScheme={colorScheme}
labelMargin={4}
/>
</Col>
</Row>
);
};
const getRowsWithRoles = () => {
const jsonMetadataObj = getJsonMetadata();
const hasCustomLabelColors = !!Object.keys(
jsonMetadataObj?.label_colors || {},
).length;
return (
<>
<Row>
<Col xs={24} md={24}>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<StyledFormItem label={t('Owners')}>
<Select
allowClear
ariaLabel={t('Owners')}
disabled={isLoading}
mode="multiple"
onChange={handleOnChangeOwners}
options={(input, page, pageSize) =>
loadAccessOptions('owners', input, page, pageSize)
}
value={handleOwnersSelectValue()}
/>
</StyledFormItem>
<p className="help-block">
{t(
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
)}
</p>
</Col>
<Col xs={24} md={12}>
<StyledFormItem label={t('Roles')}>
<Select
allowClear
ariaLabel={t('Roles')}
disabled={isLoading}
mode="multiple"
onChange={handleOnChangeRoles}
options={(input, page, pageSize) =>
loadAccessOptions('roles', input, page, pageSize)
}
value={handleRolesSelectValue()}
/>
</StyledFormItem>
<p className="help-block">
{t(
'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles are defined, then the dashboard is available to all roles.',
)}
</p>
</Col>
</Row>
<Row>
<Col xs={24} md={12}>
<ColorSchemeControlWrapper
hasCustomLabelColors={hasCustomLabelColors}
onChange={onColorSchemeChange}
colorScheme={colorScheme}
labelMargin={4}
/>
</Col>
</Row>
</>
);
};
useEffect(() => {
if (show) {
if (!currentDashboardInfo) {
fetchDashboardDetails();
} else {
handleDashboardData(currentDashboardInfo);
}
}
JsonEditor.preload();
}, [currentDashboardInfo, fetchDashboardDetails, handleDashboardData, show]);
useEffect(() => {
// the title can be changed inline in the dashboard, this catches it
if (
dashboardTitle &&
dashboardInfo &&
dashboardInfo.title !== dashboardTitle
) {
form.setFieldsValue({
...dashboardInfo,
title: dashboardTitle,
});
}
}, [dashboardInfo, dashboardTitle, form]);
return (
<Modal
show={show}
onHide={onHide}
title={t('Dashboard properties')}
footer={
<>
<Button
htmlType="button"
buttonSize="small"
onClick={onHide}
data-test="properties-modal-cancel-button"
cta
>
{t('Cancel')}
</Button>
<Button
onClick={form.submit}
buttonSize="small"
buttonStyle="primary"
className="m-r-5"
cta
>
{saveLabel}
</Button>
</>
}
responsive
>
<Form
form={form}
onFinish={onFinish}
data-test="dashboard-edit-properties-form"
layout="vertical"
initialValues={dashboardInfo}
>
<Row>
<Col xs={24} md={24}>
<h3>{t('Basic information')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormItem label={t('Title')} name="title">
<Input
data-test="dashboard-title-input"
type="text"
disabled={isLoading}
/>
</FormItem>
</Col>
<Col xs={24} md={12}>
<StyledFormItem label={t('URL slug')} name="slug">
<Input type="text" disabled={isLoading} />
</StyledFormItem>
<p className="help-block">
{t('A readable URL for your dashboard')}
</p>
</Col>
</Row>
{isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)
? getRowsWithRoles()
: getRowsWithoutRoles()}
<Row>
<Col xs={24} md={24}>
<h3>{t('Certification')}</h3>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<StyledFormItem label={t('Certified by')} name="certifiedBy">
<Input type="text" disabled={isLoading} />
</StyledFormItem>
<p className="help-block">
{t('Person or group that has certified this dashboard.')}
</p>
</Col>
<Col xs={24} md={12}>
<StyledFormItem
label={t('Certification details')}
name="certificationDetails"
>
<Input type="text" disabled={isLoading} />
</StyledFormItem>
<p className="help-block">
{t('Any additional detail to show in the certification tooltip.')}
</p>
</Col>
</Row>
<Row>
<Col xs={24} md={24}>
<h3 style={{ marginTop: '1em' }}>
<Button
buttonStyle="link"
onClick={() => setIsAdvancedOpen(!isAdvancedOpen)}
>
<i
className={`fa fa-angle-${isAdvancedOpen ? 'down' : 'right'}`}
style={{ minWidth: '1em' }}
/>
{t('Advanced')}
</Button>
</h3>
{isAdvancedOpen && (
<>
<StyledFormItem label={t('JSON metadata')}>
<StyledJsonEditor
showLoadingForImport
name="json_metadata"
value={jsonMetadata}
onChange={setJsonMetadata}
tabSize={2}
width="100%"
height="200px"
wrapEnabled
/>
</StyledFormItem>
<p className="help-block">
{t(
'This JSON object is generated dynamically when clicking the save or overwrite button in the dashboard view. It is exposed here for reference and for power users who may want to alter specific parameters.',
)}
</p>
</>
)}
</Col>
</Row>
</Form>
</Modal>
);
};
export default withToasts(PropertiesModal);

View File

@@ -21,7 +21,7 @@ import React from 'react';
import { Radio } from 'src/components/Radio';
import { RadioChangeEvent, Input } from 'src/common/components';
import Button from 'src/components/Button';
import { t, CategoricalColorNamespace, JsonResponse } from '@superset-ui/core';
import { t, JsonResponse } from '@superset-ui/core';
import ModalTrigger from 'src/components/ModalTrigger';
import Checkbox from 'src/components/Checkbox';
@@ -122,37 +122,32 @@ class SaveModal extends React.PureComponent<SaveModalProps, SaveModalState> {
dashboardInfo,
layout: positions,
customCss,
colorNamespace,
colorScheme,
expandedSlices,
dashboardId,
refreshFrequency: currentRefreshFrequency,
shouldPersistRefreshFrequency,
lastModifiedTime,
} = this.props;
const scale = CategoricalColorNamespace.getScale(
colorScheme,
colorNamespace,
);
const labelColors = colorScheme ? scale.getColorMap() : {};
// check refresh frequency is for current session or persist
const refreshFrequency = shouldPersistRefreshFrequency
? currentRefreshFrequency
: dashboardInfo.metadata?.refresh_frequency; // eslint-disable camelcase
const data = {
positions,
certified_by: dashboardInfo.certified_by,
certification_details: dashboardInfo.certification_details,
css: customCss,
color_namespace: colorNamespace,
color_scheme: colorScheme,
label_colors: labelColors,
expanded_slices: expandedSlices,
dashboard_title:
saveType === SAVE_TYPE_NEWDASHBOARD ? newDashName : dashboardTitle,
duplicate_slices: this.state.duplicateSlices,
refresh_frequency: refreshFrequency,
last_modified_time: lastModifiedTime,
owners: dashboardInfo.owners,
roles: dashboardInfo.roles,
metadata: {
...dashboardInfo?.metadata,
positions,
refresh_frequency: refreshFrequency,
},
};
if (saveType === SAVE_TYPE_NEWDASHBOARD && !newDashName) {

View File

@@ -59,10 +59,10 @@ const defaultProps = {
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
const KEYS_TO_SORT = {
slice_name: 'Name',
viz_type: 'Vis type',
datasource_name: 'Dataset',
changed_on: 'Recent',
slice_name: 'name',
viz_type: 'viz type',
datasource_name: 'dataset',
changed_on: 'recent',
};
const DEFAULT_SORT_KEY = 'changed_on';

View File

@@ -52,6 +52,7 @@ const propTypes = {
// from redux
chart: chartPropShape.isRequired,
formData: PropTypes.object.isRequired,
labelColors: PropTypes.object,
datasource: PropTypes.object,
slice: slicePropShape.isRequired,
sliceName: PropTypes.string.isRequired,
@@ -230,7 +231,7 @@ export default class Chart extends React.Component {
});
};
getChartUrl = () => getExploreLongUrl(this.props.formData);
getChartUrl = () => getExploreLongUrl(this.props.formData, null, false);
exportCSV(isFullCSV = false) {
this.props.logEvent(LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART, {
@@ -241,7 +242,7 @@ export default class Chart extends React.Component {
formData: isFullCSV
? { ...this.props.formData, row_limit: this.props.maxRows }
: this.props.formData,
resultType: 'results',
resultType: 'full',
resultFormat: 'csv',
});
}
@@ -274,6 +275,7 @@ export default class Chart extends React.Component {
editMode,
filters,
formData,
labelColors,
updateSliceName,
sliceName,
toggleExpandSlice,
@@ -396,6 +398,7 @@ export default class Chart extends React.Component {
dashboardId={dashboardId}
initialValues={initialValues}
formData={formData}
labelColors={labelColors}
ownState={ownState}
filterState={filterState}
queriesResponse={chart.queriesResponse}

View File

@@ -25,6 +25,7 @@ import { t, SafeMarkdown } from '@superset-ui/core';
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
import { MarkdownEditor } from 'src/components/AsyncAceEditor';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import DragDroppable from 'src/dashboard/components/dnd/DragDroppable';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
@@ -350,6 +351,13 @@ class Markdown extends React.PureComponent {
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}

View File

@@ -47,7 +47,6 @@ const propTypes = {
editMode: PropTypes.bool.isRequired,
renderHoverMenu: PropTypes.bool,
directPathToChild: PropTypes.arrayOf(PropTypes.string),
activeTabs: PropTypes.arrayOf(PropTypes.string),
// actions (from DashboardComponent.jsx)
logEvent: PropTypes.func.isRequired,
@@ -74,7 +73,6 @@ const defaultProps = {
availableColumnCount: 0,
columnWidth: 0,
directPathToChild: [],
activeTabs: [],
setActiveTabs() {},
onResizeStart() {},
onResize() {},
@@ -133,15 +131,12 @@ export class Tabs extends React.PureComponent {
}
componentDidMount() {
this.props.setActiveTabs([...this.props.activeTabs, this.state.activeKey]);
this.props.setActiveTabs(this.state.activeKey);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.activeKey !== this.state.activeKey) {
this.props.setActiveTabs([
...this.props.activeTabs.filter(tabId => tabId !== prevState.activeKey),
this.state.activeKey,
]);
this.props.setActiveTabs(this.state.activeKey, prevState.activeKey);
}
}
@@ -410,7 +405,6 @@ function mapStateToProps(state) {
return {
nativeFilters: state.nativeFilters,
directPathToChild: state.dashboardState.directPathToChild,
activeTabs: state.dashboardState.activeTabs,
};
}
export default connect(mapStateToProps)(Tabs);

View File

@@ -63,7 +63,8 @@ const FilterControls: FC<FilterControlsProps> = ({
dataMask: dataMaskSelected[filter.id],
}));
return buildCascadeFiltersTree(filtersWithValue);
}, [filterValues, dataMaskSelected]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(filterValues), dataMaskSelected]);
const cascadeFilterIds = new Set(cascadeFilters.map(item => item.id));
const [filtersInScope, filtersOutOfScope] = useSelectFiltersInScope(

View File

@@ -29,7 +29,7 @@ import {
getChartMetadataRegistry,
} from '@superset-ui/core';
import { useDispatch, useSelector } from 'react-redux';
import { areObjectsEqual } from 'src/reduxUtils';
import { isEqual, isEqualWith } from 'lodash';
import { getChartDataRequest } from 'src/chart/chartAction';
import Loading from 'src/components/Loading';
import BasicErrorAlert from 'src/components/ErrorMessage/BasicErrorAlert';
@@ -105,10 +105,17 @@ const FilterValue: React.FC<FilterProps> = ({
time_range,
});
const filterOwnState = filter.dataMask?.ownState || {};
// TODO: We should try to improve our useEffect hooks to depend more on
// granular information instead of big objects that require deep comparison.
const customizer = (
objValue: Partial<QueryFormData>,
othValue: Partial<QueryFormData>,
key: string,
) => (key === 'url_params' ? true : undefined);
if (
!isRefreshing &&
(!areObjectsEqual(formData, newFormData) ||
!areObjectsEqual(ownState, filterOwnState) ||
(!isEqualWith(formData, newFormData, customizer) ||
!isEqual(ownState, filterOwnState) ||
isDashboardRefreshing)
) {
setFormData(newFormData);

View File

@@ -35,7 +35,7 @@ export const StyledSpan = styled.span`
`;
export const StyledFilterTitle = styled.span`
width: ${FILTER_WIDTH}px;
width: 100%;
white-space: normal;
color: ${({ theme }) => theme.colors.grayscale.dark1};
`;
@@ -61,6 +61,7 @@ export const FilterTabTitle = styled.span`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
@keyframes tabTitleRemovalAnimation {
0%,
@@ -79,6 +80,17 @@ export const FilterTabTitle = styled.span`
animation-name: tabTitleRemovalAnimation;
animation-duration: ${REMOVAL_DELAY_SECS}s;
}
&.errored > span {
color: ${({ theme }) => theme.colors.error.base};
}
`;
const StyledWarning = styled(Icons.Warning)`
color: ${({ theme }) => theme.colors.error.base};
&.anticon {
margin-right: 0;
}
`;
const FilterTabsContainer = styled(LineEditableTabs)`
@@ -169,6 +181,7 @@ type FilterTabsProps = {
currentFilterId: string;
onEdit: (filterId: string, action: 'add' | 'remove') => void;
filterIds: string[];
erroredFilters: string[];
removedFilters: Record<string, FilterRemoval>;
restoreFilter: Function;
children: Function;
@@ -180,6 +193,7 @@ const FilterTabs: FC<FilterTabsProps> = ({
onChange,
currentFilterId,
filterIds = [],
erroredFilters = [],
removedFilters = [],
restoreFilter,
children,
@@ -217,34 +231,47 @@ const FilterTabs: FC<FilterTabsProps> = ({
),
}}
>
{filterIds.map(id => (
<LineEditableTabs.TabPane
tab={
<FilterTabTitle className={removedFilters[id] ? 'removed' : ''}>
<StyledFilterTitle>
{removedFilters[id] ? t('(Removed)') : getFilterTitle(id)}
</StyledFilterTitle>
{removedFilters[id] && (
<StyledSpan
role="button"
data-test="undo-button"
tabIndex={0}
onClick={() => restoreFilter(id)}
>
{t('Undo?')}
</StyledSpan>
)}
</FilterTabTitle>
}
key={id}
closeIcon={removedFilters[id] ? <></> : <StyledTrashIcon />}
>
{
// @ts-ignore
children(id)
}
</LineEditableTabs.TabPane>
))}
{filterIds.map(id => {
const showErroredFilter = erroredFilters.includes(id);
const filterName = getFilterTitle(id);
return (
<LineEditableTabs.TabPane
tab={
<FilterTabTitle
className={
removedFilters[id]
? 'removed'
: showErroredFilter
? 'errored'
: ''
}
>
<StyledFilterTitle>
{removedFilters[id] ? t('(Removed)') : filterName}
</StyledFilterTitle>
{!removedFilters[id] && showErroredFilter && <StyledWarning />}
{removedFilters[id] && (
<StyledSpan
role="button"
data-test="undo-button"
tabIndex={0}
onClick={() => restoreFilter(id)}
>
{t('Undo?')}
</StyledSpan>
)}
</FilterTabTitle>
}
key={id}
closeIcon={removedFilters[id] ? <></> : <StyledTrashIcon />}
>
{
// @ts-ignore
children(id)
}
</LineEditableTabs.TabPane>
);
})}
</FilterTabsContainer>
);

View File

@@ -35,6 +35,7 @@ describe('FilterScope', () => {
const mockedProps = {
filterId: 'DefaultFilterId',
restoreFilter: jest.fn(),
setErroredFilters: jest.fn(),
parentFilters: [],
save,
removedFilters: {},

View File

@@ -268,6 +268,7 @@ export interface FiltersConfigFormProps {
restoreFilter: (filterId: string) => void;
form: FormInstance<NativeFiltersForm>;
parentFilters: { id: string; title: string }[];
setErroredFilters: (f: (filters: string[]) => string[]) => void;
}
const FILTERS_WITH_ADHOC_FILTERS = ['filter_select', 'filter_range'];
@@ -304,6 +305,7 @@ const FiltersConfigForm = (
restoreFilter,
form,
parentFilters,
setErroredFilters,
}: FiltersConfigFormProps,
ref: React.RefObject<any>,
) => {
@@ -852,82 +854,104 @@ const FiltersConfigForm = (
hidden
initialValue={null}
/>
<CollapsibleControl
title={t('Filter has default value')}
initialValue={hasDefaultValue}
disabled={isRequired || defaultToFirstItem}
tooltip={defaultValueTooltip}
checked={hasDefaultValue}
onChange={value => {
setHasDefaultValue(value);
formChanged();
}}
>
{!isRemoved && (
<StyledRowSubFormItem
name={['filters', filterId, 'defaultDataMask']}
initialValue={initialDefaultValue}
data-test="default-input"
label={<StyledLabel>{t('Default Value')}</StyledLabel>}
required={hasDefaultValue}
rules={[
{
validator: () => {
if (formFilter?.defaultDataMask?.filterState?.value) {
return Promise.resolve();
}
return Promise.reject(
new Error(t('Default value is required')),
);
},
},
]}
>
{error ? (
<BasicErrorAlert
title={t('Cannot load filter')}
body={error}
level="error"
/>
) : showDefaultValue ? (
<DefaultValueContainer>
<DefaultValue
setDataMask={dataMask => {
if (
!isEqual(
initialDefaultValue?.filterState?.value,
dataMask?.filterState?.value,
)
) {
formChanged();
<CleanFormItem name={['filters', filterId, 'defaultValue']}>
<CollapsibleControl
checked={hasDefaultValue}
disabled={isRequired || defaultToFirstItem}
initialValue={hasDefaultValue}
title={t('Filter has default value')}
tooltip={defaultValueTooltip}
onChange={value => {
setHasDefaultValue(value);
formChanged();
}}
>
{!isRemoved && (
<StyledRowSubFormItem
name={['filters', filterId, 'defaultDataMask']}
initialValue={initialDefaultValue}
data-test="default-input"
label={<StyledLabel>{t('Default Value')}</StyledLabel>}
required={hasDefaultValue}
rules={[
{
validator: () => {
if (formFilter?.defaultDataMask?.filterState?.value) {
// requires managing the error as the DefaultValue
// component does not use an Antdesign compatible input
const formValidationFields = form.getFieldsError();
setErroredFilters(prevErroredFilters => {
if (
prevErroredFilters.length &&
!formValidationFields.find(
f => f.errors.length > 0,
)
) {
return [];
}
return prevErroredFilters;
});
return Promise.resolve();
}
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: dataMask,
setErroredFilters(prevErroredFilters => {
if (prevErroredFilters.includes(filterId)) {
return prevErroredFilters;
}
return [...prevErroredFilters, filterId];
});
form.validateFields([
['filters', filterId, 'defaultDataMask'],
]);
forceUpdate();
}}
hasDefaultValue={hasDefaultValue}
filterId={filterId}
hasDataset={hasDataset}
form={form}
formData={newFormData}
enableNoResults={enableNoResults}
return Promise.reject(
new Error(t('Default value is required')),
);
},
},
]}
>
{error ? (
<BasicErrorAlert
title={t('Cannot load filter')}
body={error}
level="error"
/>
{hasDataset && datasetId && (
<Tooltip title={t('Refresh the default values')}>
<RefreshIcon onClick={() => refreshHandler(true)} />
</Tooltip>
)}
</DefaultValueContainer>
) : (
t('Fill all required fields to enable "Default Value"')
)}
</StyledRowSubFormItem>
)}
</CollapsibleControl>
) : showDefaultValue ? (
<DefaultValueContainer>
<DefaultValue
setDataMask={dataMask => {
if (
!isEqual(
initialDefaultValue?.filterState?.value,
dataMask?.filterState?.value,
)
) {
formChanged();
}
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: dataMask,
});
form.validateFields([
['filters', filterId, 'defaultDataMask'],
]);
forceUpdate();
}}
hasDefaultValue={hasDefaultValue}
filterId={filterId}
hasDataset={hasDataset}
form={form}
formData={newFormData}
enableNoResults={enableNoResults}
/>
{hasDataset && datasetId && (
<Tooltip title={t('Refresh the default values')}>
<RefreshIcon onClick={() => refreshHandler(true)} />
</Tooltip>
)}
</DefaultValueContainer>
) : (
t('Fill all required fields to enable "Default Value"')
)}
</StyledRowSubFormItem>
)}
</CollapsibleControl>
</CleanFormItem>
{Object.keys(controlItems)
.filter(key => BASIC_CONTROL_ITEMS.includes(key))
.map(key => controlItems[key].element)}
@@ -939,213 +963,221 @@ const FiltersConfigForm = (
key={FilterPanels.advanced.key}
>
{isCascadingFilter && (
<CollapsibleControl
title={t('Filter is hierarchical')}
initialValue={hasParentFilter}
onChange={checked => {
formChanged();
if (checked) {
// execute after render
setTimeout(
() =>
form.validateFields([
['filters', filterId, 'parentFilter'],
]),
0,
);
}
}}
<CleanFormItem
name={['filters', filterId, 'hierarchicalFilter']}
>
<StyledRowSubFormItem
name={['filters', filterId, 'parentFilter']}
label={<StyledLabel>{t('Parent filter')}</StyledLabel>}
initialValue={parentFilter}
normalize={value => (value ? { value } : undefined)}
data-test="parent-filter-input"
required
rules={[
{
required: true,
message: t('Parent filter is required'),
},
]}
<CollapsibleControl
title={t('Filter is hierarchical')}
initialValue={hasParentFilter}
onChange={checked => {
formChanged();
if (checked) {
// execute after render
setTimeout(
() =>
form.validateFields([
['filters', filterId, 'parentFilter'],
]),
0,
);
}
}}
>
<ParentSelect />
</StyledRowSubFormItem>
</CollapsibleControl>
<StyledRowSubFormItem
name={['filters', filterId, 'parentFilter']}
label={<StyledLabel>{t('Parent filter')}</StyledLabel>}
initialValue={parentFilter}
normalize={value => (value ? { value } : undefined)}
data-test="parent-filter-input"
required
rules={[
{
required: true,
message: t('Parent filter is required'),
},
]}
>
<ParentSelect />
</StyledRowSubFormItem>
</CollapsibleControl>
</CleanFormItem>
)}
{Object.keys(controlItems)
.filter(key => !BASIC_CONTROL_ITEMS.includes(key))
.map(key => controlItems[key].element)}
{hasDataset && hasAdditionalFilters && (
<CollapsibleControl
title={t('Pre-filter available values')}
initialValue={hasPreFilter}
onChange={checked => {
formChanged();
if (checked) {
validatePreFilter();
}
}}
>
<StyledRowSubFormItem
name={['filters', filterId, 'adhoc_filters']}
initialValue={filterToEdit?.adhoc_filters}
required
rules={[
{
validator: preFilterValidator,
},
]}
>
<AdhocFilterControl
columns={
datasetDetails?.columns?.filter(
(c: ColumnMeta) => c.filterable,
) || []
}
savedMetrics={datasetDetails?.metrics || []}
datasource={datasetDetails}
onChange={(filters: AdhocFilter[]) => {
setNativeFilterFieldValues(form, filterId, {
adhoc_filters: filters,
});
forceUpdate();
<CleanFormItem name={['filters', filterId, 'preFilter']}>
<CollapsibleControl
initialValue={hasPreFilter}
title={t('Pre-filter available values')}
onChange={checked => {
formChanged();
if (checked) {
validatePreFilter();
}}
label={
<span>
<StyledLabel>{t('Pre-filter')}</StyledLabel>
{!hasTimeRange && <StyledAsterisk />}
</span>
}
/>
</StyledRowSubFormItem>
{showTimeRangePicker && (
<StyledRowFormItem
name={['filters', filterId, 'time_range']}
label={<StyledLabel>{t('Time range')}</StyledLabel>}
initialValue={filterToEdit?.time_range || 'No filter'}
required={!hasAdhoc}
}}
>
<StyledRowSubFormItem
name={['filters', filterId, 'adhoc_filters']}
initialValue={filterToEdit?.adhoc_filters}
required
rules={[
{
validator: preFilterValidator,
},
]}
>
<DateFilterControl
name="time_range"
endpoints={['inclusive', 'exclusive']}
onChange={timeRange => {
<AdhocFilterControl
columns={
datasetDetails?.columns?.filter(
(c: ColumnMeta) => c.filterable,
) || []
}
savedMetrics={datasetDetails?.metrics || []}
datasource={datasetDetails}
onChange={(filters: AdhocFilter[]) => {
setNativeFilterFieldValues(form, filterId, {
time_range: timeRange,
adhoc_filters: filters,
});
forceUpdate();
validatePreFilter();
}}
/>
</StyledRowFormItem>
)}
{hasTimeRange && (
<StyledRowFormItem
name={['filters', filterId, 'granularity_sqla']}
label={
<>
<StyledLabel>{t('Time column')}</StyledLabel>&nbsp;
<InfoTooltipWithTrigger
placement="top"
tooltip={t(
'Optional time column if time range should apply to another column than the default time column',
)}
/>
</>
}
initialValue={filterToEdit?.granularity_sqla}
>
<ColumnSelect
allowClear
form={form}
formField="granularity_sqla"
filterId={filterId}
filterValues={(column: Column) => !!column.is_dttm}
datasetId={datasetId}
onChange={column => {
// We need reset default value when when column changed
setNativeFilterFieldValues(form, filterId, {
granularity_sqla: column,
});
forceUpdate();
}}
/>
</StyledRowFormItem>
)}
</CollapsibleControl>
)}
{formFilter?.filterType !== 'filter_range' && (
<CollapsibleControl
title={t('Sort filter values')}
onChange={checked => {
onSortChanged(checked || undefined);
formChanged();
}}
initialValue={hasSorting}
>
<StyledRowFormItem
name={[
'filters',
filterId,
'controlValues',
'sortAscending',
]}
initialValue={sort}
label={<StyledLabel>{t('Sort type')}</StyledLabel>}
>
<Radio.Group
onChange={value => {
onSortChanged(value.target.value);
}}
>
<Radio value>{t('Sort ascending')}</Radio>
<Radio value={false}>{t('Sort descending')}</Radio>
</Radio.Group>
</StyledRowFormItem>
{hasMetrics && (
<StyledRowSubFormItem
name={['filters', filterId, 'sortMetric']}
initialValue={filterToEdit?.sortMetric}
label={
<>
<StyledLabel>{t('Sort Metric')}</StyledLabel>&nbsp;
<InfoTooltipWithTrigger
placement="top"
tooltip={t(
'If a metric is specified, sorting will be done based on the metric value',
)}
/>
</>
}
data-test="field-input"
>
<Select
allowClear
ariaLabel={t('Sort metric')}
name="sortMetric"
options={metrics.map((metric: Metric) => ({
value: metric.metric_name,
label: metric.verbose_name ?? metric.metric_name,
}))}
onChange={value => {
if (value !== undefined) {
setNativeFilterFieldValues(form, filterId, {
sortMetric: value,
});
forceUpdate();
}
}}
label={
<span>
<StyledLabel>{t('Pre-filter')}</StyledLabel>
{!hasTimeRange && <StyledAsterisk />}
</span>
}
/>
</StyledRowSubFormItem>
)}
</CollapsibleControl>
{showTimeRangePicker && (
<StyledRowFormItem
name={['filters', filterId, 'time_range']}
label={<StyledLabel>{t('Time range')}</StyledLabel>}
initialValue={filterToEdit?.time_range || 'No filter'}
required={!hasAdhoc}
rules={[
{
validator: preFilterValidator,
},
]}
>
<DateFilterControl
name="time_range"
endpoints={['inclusive', 'exclusive']}
onChange={timeRange => {
setNativeFilterFieldValues(form, filterId, {
time_range: timeRange,
});
forceUpdate();
validatePreFilter();
}}
/>
</StyledRowFormItem>
)}
{hasTimeRange && (
<StyledRowFormItem
name={['filters', filterId, 'granularity_sqla']}
label={
<>
<StyledLabel>{t('Time column')}</StyledLabel>&nbsp;
<InfoTooltipWithTrigger
placement="top"
tooltip={t(
'Optional time column if time range should apply to another column than the default time column',
)}
/>
</>
}
initialValue={filterToEdit?.granularity_sqla}
>
<ColumnSelect
allowClear
form={form}
formField="granularity_sqla"
filterId={filterId}
filterValues={(column: Column) => !!column.is_dttm}
datasetId={datasetId}
onChange={column => {
// We need reset default value when when column changed
setNativeFilterFieldValues(form, filterId, {
granularity_sqla: column,
});
forceUpdate();
}}
/>
</StyledRowFormItem>
)}
</CollapsibleControl>
</CleanFormItem>
)}
{formFilter?.filterType !== 'filter_range' && (
<CleanFormItem name={['filters', filterId, 'sortFilter']}>
<CollapsibleControl
initialValue={hasSorting}
title={t('Sort filter values')}
onChange={checked => {
onSortChanged(checked || undefined);
formChanged();
}}
>
<StyledRowFormItem
name={[
'filters',
filterId,
'controlValues',
'sortAscending',
]}
initialValue={sort}
label={<StyledLabel>{t('Sort type')}</StyledLabel>}
>
<Radio.Group
onChange={value => {
onSortChanged(value.target.value);
}}
>
<Radio value>{t('Sort ascending')}</Radio>
<Radio value={false}>{t('Sort descending')}</Radio>
</Radio.Group>
</StyledRowFormItem>
{hasMetrics && (
<StyledRowSubFormItem
name={['filters', filterId, 'sortMetric']}
initialValue={filterToEdit?.sortMetric}
label={
<>
<StyledLabel>{t('Sort Metric')}</StyledLabel>&nbsp;
<InfoTooltipWithTrigger
placement="top"
tooltip={t(
'If a metric is specified, sorting will be done based on the metric value',
)}
/>
</>
}
data-test="field-input"
>
<Select
allowClear
ariaLabel={t('Sort metric')}
name="sortMetric"
options={metrics.map((metric: Metric) => ({
value: metric.metric_name,
label: metric.verbose_name ?? metric.metric_name,
}))}
onChange={value => {
if (value !== undefined) {
setNativeFilterFieldValues(form, filterId, {
sortMetric: value,
});
forceUpdate();
}
}}
/>
</StyledRowSubFormItem>
)}
</CollapsibleControl>
</CleanFormItem>
)}
</Collapse.Panel>
)}

View File

@@ -16,10 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useMemo, useState, useRef } from 'react';
import { uniq, debounce } from 'lodash';
import { t, styled } from '@superset-ui/core';
import { SLOW_DEBOUNCE } from 'src/constants';
import React, {
useEffect,
useCallback,
useMemo,
useState,
useRef,
} from 'react';
import { uniq, isEqual, sortBy, debounce } from 'lodash';
import { t, styled, SLOW_DEBOUNCE } from '@superset-ui/core';
import { Form } from 'src/common/components';
import { StyledModal } from 'src/components/Modal';
import ErrorBoundary from 'src/components/ErrorBoundary';
@@ -126,6 +131,7 @@ export function FiltersConfigModal({
const [currentFilterId, setCurrentFilterId] = useState(
initialCurrentFilterId,
);
const [erroredFilters, setErroredFilters] = useState<string[]>([]);
// 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)
@@ -174,12 +180,13 @@ export function FiltersConfigModal({
setSaveAlertVisible(false);
setFormValues({ filters: {} });
form.setFieldsValue({ changed: false });
setErroredFilters([]);
};
const getFilterTitle = (id: string) =>
formValues.filters[id]?.name ??
filterConfigMap[id]?.name ??
t('New filter');
formValues.filters[id]?.name ||
filterConfigMap[id]?.name ||
t('[untitled]');
const getParentFilters = (id: string) =>
filterIds
@@ -217,6 +224,32 @@ export function FiltersConfigModal({
}
};
const handleErroredFilters = useCallback(() => {
// managing left pane errored filters indicators
const formValidationFields = form.getFieldsError();
const erroredFiltersIds: string[] = [];
formValidationFields.forEach(field => {
const filterId = field.name[1] as string;
if (field.errors.length > 0 && !erroredFiltersIds.includes(filterId)) {
erroredFiltersIds.push(filterId);
}
});
// no form validation issues found, resets errored filters
if (!erroredFiltersIds.length && erroredFilters.length > 0) {
setErroredFilters([]);
return;
}
// form validation issues found, sets errored filters
if (
erroredFiltersIds.length > 0 &&
!isEqual(sortBy(erroredFilters), sortBy(erroredFiltersIds))
) {
setErroredFilters(erroredFiltersIds);
}
}, [form, erroredFilters]);
const handleSave = async () => {
const values: NativeFiltersForm | null = await validateForm(
form,
@@ -227,6 +260,8 @@ export function FiltersConfigModal({
setCurrentFilterId,
);
handleErroredFilters();
if (values) {
cleanDeletedParents(values);
createHandleSave(
@@ -259,20 +294,28 @@ export function FiltersConfigModal({
const onValuesChange = useMemo(
() =>
debounce((changes: any, 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);
if (changes.filters) {
if (
Object.values(changes.filters).some(
(filter: any) => filter.name != null,
)
) {
// we only need to set this if a name changed
setFormValues(values);
}
handleErroredFilters();
}
setSaveAlertVisible(false);
}, SLOW_DEBOUNCE),
[],
[handleErroredFilters],
);
useEffect(() => {
setErroredFilters(prevErroredFilters =>
prevErroredFilters.filter(f => !removedFilters[f]),
);
}, [removedFilters]);
return (
<StyledModalWrapper
visible={isOpen}
@@ -289,6 +332,7 @@ export function FiltersConfigModal({
onDismiss={() => setSaveAlertVisible(false)}
onCancel={handleCancel}
handleSave={handleSave}
canSave={!erroredFilters.length}
saveAlertVisible={saveAlertVisible}
onConfirmCancel={handleConfirmCancel}
/>
@@ -303,6 +347,7 @@ export function FiltersConfigModal({
layout="vertical"
>
<FilterTabs
erroredFilters={erroredFilters}
onEdit={handleTabEdit}
onChange={setCurrentFilterId}
getFilterTitle={getFilterTitle}
@@ -320,6 +365,7 @@ export function FiltersConfigModal({
removedFilters={removedFilters}
restoreFilter={restoreFilter}
parentFilters={getParentFilters(id)}
setErroredFilters={setErroredFilters}
/>
)}
</FilterTabs>

View File

@@ -27,9 +27,11 @@ type FooterProps = {
onConfirmCancel: OnClickHandler;
onDismiss: OnClickHandler;
saveAlertVisible: boolean;
canSave?: boolean;
};
const Footer: FC<FooterProps> = ({
canSave = true,
onCancel,
handleSave,
onDismiss,
@@ -60,6 +62,7 @@ const Footer: FC<FooterProps> = ({
{t('Cancel')}
</Button>
<Button
disabled={!canSave}
key="submit"
buttonStyle="primary"
onClick={handleSave}

View File

@@ -19,6 +19,7 @@
import { FormInstance } from 'antd/lib/form';
import shortid from 'shortid';
import { getInitialDataMask } from 'src/dataMask/reducer';
import { t } from '@superset-ui/core';
import { FilterRemoval, NativeFiltersForm } from './types';
import { Filter, FilterConfiguration, Target } from '../types';
@@ -66,7 +67,7 @@ export const validateForm = async (
addValidationError(
filterId,
'parentFilter',
'Cannot create cyclic hierarchy',
t('Cannot create cyclic hierarchy'),
);
return false;
}
@@ -103,7 +104,8 @@ export const validateForm = async (
field => field.name[0] === 'filters',
);
if (filterError) {
setCurrentFilterId(filterError.name[1]);
const filterId = filterError.name[1];
setCurrentFilterId(filterId);
}
}
return null;

View File

@@ -61,7 +61,7 @@ function mapStateToProps(
(chart && chart.form_data && datasources[chart.form_data.datasource]) ||
PLACEHOLDER_DATASOURCE;
const { colorScheme, colorNamespace } = dashboardState;
const labelColors = dashboardInfo?.metadata?.label_colors || {};
// note: this method caches filters if possible to prevent render cascades
const formData = getFormDataWithExtraFilters({
layout: dashboardLayout.present,
@@ -75,6 +75,7 @@ function mapStateToProps(
sliceId: id,
nativeFilters,
dataMask,
labelColors,
});
formData.dashboardId = dashboardInfo.id;
@@ -82,6 +83,7 @@ function mapStateToProps(
return {
chart,
datasource,
labelColors,
slice: sliceEntities.slices[id],
timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
filters: getActiveFilters() || EMPTY_OBJECT,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, FC } from 'react';
import React, { useEffect, useRef, FC } from 'react';
import { t } from '@superset-ui/core';
import { useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
@@ -57,17 +57,16 @@ const DashboardPage: FC = () => {
const { result: datasets, error: datasetsApiError } = useDashboardDatasets(
idOrSlug,
);
const isDashboardHydrated = useRef(false);
const error = dashboardApiError || chartsApiError;
const readyToRender = Boolean(dashboard && charts);
const { dashboard_title, css } = dashboard || {};
useEffect(() => {
if (readyToRender) {
dispatch(hydrateDashboard(dashboard, charts));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readyToRender]);
if (readyToRender && !isDashboardHydrated.current) {
isDashboardHydrated.current = true;
dispatch(hydrateDashboard(dashboard, charts));
}
useEffect(() => {
if (dashboard_title) {

View File

@@ -150,9 +150,12 @@ export default function dashboardStateReducer(state = {}, action) {
};
},
[SET_ACTIVE_TABS]() {
const newActiveTabs = new Set(state.activeTabs);
newActiveTabs.delete(action.prevTabId);
newActiveTabs.add(action.tabId);
return {
...state,
activeTabs: action.tabIds,
activeTabs: Array.from(newActiveTabs),
};
},
[SET_FOCUSED_FILTER_FIELD]() {

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 dashboardStateReducer from './dashboardState';
import { setActiveTabs } from '../actions/dashboardState';
describe('DashboardState reducer', () => {
it('SET_ACTIVE_TABS', () => {
expect(
dashboardStateReducer({ activeTabs: [] }, setActiveTabs('tab1')),
).toEqual({ activeTabs: ['tab1'] });
expect(
dashboardStateReducer({ activeTabs: ['tab1'] }, setActiveTabs('tab1')),
).toEqual({ activeTabs: ['tab1'] });
expect(
dashboardStateReducer(
{ activeTabs: ['tab1'] },
setActiveTabs('tab2', 'tab1'),
),
).toEqual({ activeTabs: ['tab2'] });
});
});

View File

@@ -42,7 +42,7 @@
cursor: move;
}
#brace-editor {
#ace-editor {
border: none;
}
}

View File

@@ -16,11 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
CategoricalColorNamespace,
DataRecordFilters,
JsonObject,
} from '@superset-ui/core';
import { DataRecordFilters, JsonObject } from '@superset-ui/core';
import { ChartQueryPayload, Charts, LayoutItem } from 'src/dashboard/types';
import { getExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
import { DataMaskStateWithId } from 'src/dataMask/types';
@@ -45,6 +41,7 @@ export interface GetFormDataWithExtraFiltersArguments {
sliceId: number;
dataMask: DataMaskStateWithId;
nativeFilters: NativeFiltersState;
labelColors?: Record<string, string>;
}
// this function merge chart's formData with dashboard filters value,
@@ -61,11 +58,8 @@ export default function getFormDataWithExtraFilters({
sliceId,
layout,
dataMask,
labelColors,
}: GetFormDataWithExtraFiltersArguments) {
// Propagate color mapping to chart
const scale = CategoricalColorNamespace.getScale(colorScheme, colorNamespace);
const labelColors = scale.getColorMap();
// if dashboard metadata + filters have not changed, use cache if possible
const cachedFormData = cachedFormdataByChart[sliceId];
if (
@@ -109,11 +103,12 @@ export default function getFormDataWithExtraFilters({
const formData = {
...chart.formData,
...(colorScheme && { color_scheme: colorScheme }),
label_colors: labelColors,
...(colorScheme && { color_scheme: colorScheme }),
extra_filters: getEffectiveExtraFilters(filters),
...extraData,
};
cachedFiltersByChart[sliceId] = filters;
cachedFormdataByChart[sliceId] = { ...formData, dataMask };

View File

@@ -24,7 +24,7 @@ export default function getLeafComponentIdFromPath(directPathToChild = []) {
while (currentPath.length) {
const componentId = currentPath.pop();
const componentType = componentId.split('-')[0];
const componentType = componentId && componentId.split('-')[0];
if (!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)) {
return componentId;

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import rison from 'rison';
import React from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { Row, Col } from 'src/common/components';
import { Radio } from 'src/components/Radio';
@@ -26,6 +26,8 @@ import Alert from 'src/components/Alert';
import Badge from 'src/components/Badge';
import shortid from 'shortid';
import { styled, SupersetClient, t, supersetTheme } from '@superset-ui/core';
import { Select } from 'src/components';
import { FormLabel } from 'src/components/Form';
import Button from 'src/components/Button';
import Tabs from 'src/components/Tabs';
import CertifiedIcon from 'src/components/CertifiedIcon';
@@ -40,9 +42,7 @@ import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import TextControl from 'src/explore/components/controls/TextControl';
import { Select } from 'src/components';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import SelectAsyncControl from 'src/explore/components/controls/SelectAsyncControl';
import SpatialControl from 'src/explore/components/controls/SpatialControl';
import CollectionTable from 'src/CRUD/CollectionTable';
@@ -374,12 +374,44 @@ const defaultProps = {
onChange: () => {},
};
function OwnersSelector({ datasource, onChange }) {
const loadOptions = useCallback((search = '', page, pageSize) => {
const query = rison.encode({ filter: search, page, page_size: pageSize });
return SupersetClient.get({
endpoint: `/api/v1/dataset/related/owners?q=${query}`,
}).then(response => ({
data: response.json.result.map(item => ({
value: item.value,
label: item.text,
})),
totalCount: response.json.count,
}));
}, []);
return (
<Select
ariaLabel={t('Select owners')}
mode="multiple"
name="owners"
value={datasource.owners}
options={loadOptions}
onChange={onChange}
header={<FormLabel>{t('Owners')}</FormLabel>}
allowClear
/>
);
}
class DatasourceEditor extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
datasource: {
...props.datasource,
owners: props.datasource.owners.map(owner => ({
value: owner.value || owner.id,
label: owner.label || `${owner.first_name} ${owner.last_name}`,
})),
metrics: props.datasource.metrics?.map(metric => {
const {
certified_by: certifiedByMetric,
@@ -439,7 +471,6 @@ class DatasourceEditor extends React.PureComponent {
const { datasourceType, datasource } = this.state;
const sql =
datasourceType === DATASOURCE_TYPES.physical.key ? '' : datasource.sql;
const newDatasource = {
...this.state.datasource,
sql,
@@ -457,6 +488,7 @@ class DatasourceEditor extends React.PureComponent {
}
onDatasourcePropChange(attr, value) {
if (value === undefined) return; // if value is undefined do not update state
const datasource = { ...this.state.datasource, [attr]: value };
this.setState(
prevState => ({
@@ -717,23 +749,11 @@ class DatasourceEditor extends React.PureComponent {
}
/>
)}
<Field
fieldKey="owners"
label={t('Owners')}
description={t('Owners of the dataset')}
control={
<SelectAsyncControl
dataEndpoint="api/v1/dataset/related/owners"
multi
mutator={data =>
data.result.map(pk => ({
value: pk.value,
label: `${pk.text}`,
}))
}
/>
}
controlProps={{}}
<OwnersSelector
datasource={datasource}
onChange={newOwners => {
this.onDatasourceChange({ ...datasource, owners: newOwners });
}}
/>
</Fieldset>
);

View File

@@ -99,7 +99,6 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
currentDatasource.schema;
setIsSaving(true);
SupersetClient.post({
endpoint: '/datasource/save/',
postPayload: {
@@ -119,12 +118,18 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
}),
),
type: currentDatasource.type || currentDatasource.datasource_type,
owners: currentDatasource.owners.map(
(o: Record<string, number>) => o.value || o.id,
),
},
},
})
.then(({ json }) => {
addSuccessToast(t('The dataset has been saved'));
onDatasourceSave(json);
onDatasourceSave({
...json,
owners: currentDatasource.owners,
});
onHide();
})
.catch(response => {

View File

@@ -69,7 +69,9 @@ export default function Control(props: ControlProps) {
const ControlComponent = typeof type === 'string' ? controlMap[type] : type;
if (!ControlComponent) {
return <>Unknown controlType: {type}</>;
// eslint-disable-next-line no-console
console.warn(`Unknown controlType: ${type}`);
return null;
}
return (

View File

@@ -21,7 +21,12 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import PropTypes from 'prop-types';
import Icons from 'src/components/Icons';
import { styled, t } from '@superset-ui/core';
import {
CategoricalColorNamespace,
SupersetClient,
styled,
t,
} from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import ReportModal from 'src/components/ReportModal';
import {
@@ -31,16 +36,17 @@ import {
} from 'src/reports/actions/reports';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import HeaderReportActionsDropdown from 'src/components/ReportModal/HeaderReportActionsDropdown';
import { chartPropShape } from '../../dashboard/util/propShapes';
import { chartPropShape } from 'src/dashboard/util/propShapes';
import EditableTitle from 'src/components/EditableTitle';
import AlteredSliceTag from 'src/components/AlteredSliceTag';
import FaveStar from 'src/components/FaveStar';
import Timer from 'src/components/Timer';
import CachedLabel from 'src/components/CachedLabel';
import PropertiesModal from 'src/explore/components/PropertiesModal';
import { sliceUpdated } from 'src/explore/actions/exploreActions';
import CertifiedIcon from 'src/components/CertifiedIcon';
import ExploreActionButtons from './ExploreActionButtons';
import RowCountLabel from './RowCountLabel';
import EditableTitle from '../../components/EditableTitle';
import AlteredSliceTag from '../../components/AlteredSliceTag';
import FaveStar from '../../components/FaveStar';
import Timer from '../../components/Timer';
import CachedLabel from '../../components/CachedLabel';
import PropertiesModal from './PropertiesModal';
import { sliceUpdated } from '../actions/exploreActions';
const CHART_STATUS_MAP = {
failed: 'danger',
@@ -53,6 +59,7 @@ const propTypes = {
addHistory: PropTypes.func,
can_overwrite: PropTypes.bool.isRequired,
can_download: PropTypes.bool.isRequired,
dashboardId: PropTypes.number,
isStarred: PropTypes.bool.isRequired,
slice: PropTypes.object,
sliceName: PropTypes.string,
@@ -114,9 +121,11 @@ export class ExploreChartHeader extends React.PureComponent {
this.showReportModal = this.showReportModal.bind(this);
this.hideReportModal = this.hideReportModal.bind(this);
this.renderReportModal = this.renderReportModal.bind(this);
this.fetchChartDashboardData = this.fetchChartDashboardData.bind(this);
}
componentDidMount() {
const { dashboardId } = this.props;
if (this.canAddReports()) {
const { user, chart } = this.props;
// this is in the case that there is an anonymous user.
@@ -127,10 +136,40 @@ export class ExploreChartHeader extends React.PureComponent {
chart.id,
);
}
if (dashboardId) {
this.fetchChartDashboardData();
}
}
async fetchChartDashboardData() {
const { dashboardId, slice } = this.props;
const response = await SupersetClient.get({
endpoint: `/api/v1/chart/${slice.slice_id}`,
});
const chart = response.json.result;
const dashboards = chart.dashboards || [];
const dashboard =
dashboardId &&
dashboards.length &&
dashboards.find(d => d.id === dashboardId);
if (dashboard && dashboard.json_metadata) {
// setting the chart to use the dashboard custom label colors if any
const labelColors =
JSON.parse(dashboard.json_metadata).label_colors || {};
const categoricalNamespace = CategoricalColorNamespace.getNamespace();
Object.keys(labelColors).forEach(label => {
categoricalNamespace.setColor(label, labelColors[label]);
});
}
}
getSliceName() {
return this.props.sliceName || t('%s - untitled', this.props.table_name);
const { sliceName, table_name: tableName } = this.props;
const title = sliceName || t('%s - untitled', tableName);
return title;
}
postChartFormData() {
@@ -206,7 +245,7 @@ export class ExploreChartHeader extends React.PureComponent {
}
render() {
const { user, form_data: formData } = this.props;
const { user, form_data: formData, slice } = this.props;
const {
chartStatus,
chartUpdateEndTime,
@@ -222,6 +261,14 @@ export class ExploreChartHeader extends React.PureComponent {
return (
<StyledHeader id="slice-header" className="panel-title-large">
<div className="title-panel">
{slice?.certified_by && (
<>
<CertifiedIcon
certifiedBy={slice.certified_by}
details={slice.certification_details}
/>{' '}
</>
)}
<EditableTitle
title={this.getSliceName()}
canEdit={!this.props.slice || this.props.can_overwrite}

View File

@@ -38,6 +38,7 @@ const propTypes = {
can_overwrite: PropTypes.bool.isRequired,
can_download: PropTypes.bool.isRequired,
datasource: PropTypes.object,
dashboardId: PropTypes.number,
column_formats: PropTypes.object,
containerId: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
@@ -291,6 +292,7 @@ const ExploreChartPanel = props => {
addHistory={props.addHistory}
can_overwrite={props.can_overwrite}
can_download={props.can_download}
dashboardId={props.dashboardId}
isStarred={props.isStarred}
slice={props.slice}
sliceName={props.sliceName}

Some files were not shown because too many files have changed in this diff Show More