Compare commits

...

60 Commits

Author SHA1 Message Date
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
190 changed files with 6073 additions and 21791 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

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

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

@@ -77,7 +77,7 @@ flask==1.1.4
# flask-openid
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==3.3.3
flask-appbuilder==3.3.4
# via apache-superset
flask-babel==1.0.0
# via flask-appbuilder
@@ -257,7 +257,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

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:
@@ -40,7 +40,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

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.3.4, <4.0.0",
"flask-caching>=1.10.0",
"flask-compress",
"flask-talisman",
@@ -106,7 +106,7 @@ setup(
"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 +169,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();
@@ -94,6 +97,10 @@ describe('Dashboard edit action', () => {
.get('[data-test="dashboard-title-input"]')
.type(`{selectall}{backspace}${dashboardTitle}`);
cy.wait('@dashboardGet').then(() => {
selectColorScheme('d3Category20b');
});
// save edit changes
cy.get('.ant-modal-footer')
.contains('Save')
@@ -146,7 +153,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 +184,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');
});

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

@@ -94,10 +94,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 +127,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

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

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

@@ -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 -06:00 (Mountain Daylight 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

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

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

@@ -220,6 +220,55 @@ class Dashboard extends React.PureComponent {
return Object.values(this.props.charts);
}
isFilterKeyRemoved(filterKey) {
const { appliedFilters } = this;
const { activeFilters } = this.props;
// refresh charts if a filter was removed, added, or changed
const currFilterKeys = Object.keys(activeFilters);
const appliedFilterKeys = Object.keys(appliedFilters);
return (
!currFilterKeys.includes(filterKey) &&
appliedFilterKeys.includes(filterKey)
);
}
isFilterKeyNewlyAdded(filterKey) {
const { appliedFilters } = this;
const appliedFilterKeys = Object.keys(appliedFilters);
return !appliedFilterKeys.includes(filterKey);
}
isFilterKeyChangedValue(filterKey) {
const { appliedFilters } = this;
const { activeFilters } = this.props;
return !areObjectsEqual(
appliedFilters[filterKey].values,
activeFilters[filterKey].values,
{
ignoreUndefined: true,
},
);
}
isFilterKeyChangedScope(filterKey) {
const { appliedFilters } = this;
const { activeFilters } = this.props;
return !areObjectsEqual(
appliedFilters[filterKey].scope,
activeFilters[filterKey].scope,
);
}
hasFilterKeyValues(filterKey) {
const { appliedFilters } = this;
return Object.keys(appliedFilters[filterKey]?.values ?? []).length;
}
applyFilters() {
const { appliedFilters } = this;
const { activeFilters, ownDataCharts } = this.props;
@@ -235,37 +284,21 @@ class Dashboard extends React.PureComponent {
);
[...allKeys].forEach(filterKey => {
if (
!currFilterKeys.includes(filterKey) &&
appliedFilterKeys.includes(filterKey)
this.isFilterKeyRemoved(filterKey) ||
this.isFilterKeyNewlyAdded(filterKey)
) {
// filterKey is removed?
affectedChartIds.push(...appliedFilters[filterKey].scope);
} else if (!appliedFilterKeys.includes(filterKey)) {
// filterKey is newly added?
affectedChartIds.push(...activeFilters[filterKey].scope);
// check if there are values in filter, if no, there is was added only ownState, so no need reload other charts
if (this.hasFilterKeyValues(filterKey)) {
affectedChartIds.push(...appliedFilters[filterKey].scope);
}
} else {
// if filterKey changes value,
// update charts in its scope
if (
!areObjectsEqual(
appliedFilters[filterKey].values,
activeFilters[filterKey].values,
{
ignoreUndefined: true,
},
)
) {
if (this.isFilterKeyChangedValue(filterKey)) {
affectedChartIds.push(...activeFilters[filterKey].scope);
}
// if filterKey changes scope,
// update all charts in its scope
if (
!areObjectsEqual(
appliedFilters[filterKey].scope,
activeFilters[filterKey].scope,
)
) {
if (this.isFilterKeyChangedScope(filterKey)) {
const chartsInScope = (activeFilters[filterKey].scope || []).concat(
appliedFilters[filterKey].scope || [],
);

View File

@@ -20,7 +20,7 @@
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 {
@@ -333,19 +333,10 @@ class Header extends React.PureComponent {
lastModifiedTime,
} = 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 };
}
const labelColors =
colorScheme && dashboardInfo?.metadata?.label_colors
? dashboardInfo.metadata.label_colors
: {};
// check refresh frequency is for current session or persist
const refreshFrequency = shouldPersistRefreshFrequency

View File

@@ -29,7 +29,6 @@ import {
t,
SupersetClient,
getCategoricalSchemeRegistry,
CategoricalColorNamespace,
} from '@superset-ui/core';
import Modal from 'src/components/Modal';
@@ -140,14 +139,15 @@ class PropertiesModal extends React.PureComponent {
JsonEditor.preload();
}
onColorSchemeChange(value, { updateMetadata = true } = {}) {
onColorSchemeChange(colorScheme, { 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)) {
if (!colorScheme || !colorChoices.includes(colorScheme)) {
Modal.error({
title: 'Error',
content: t('A valid color scheme is required'),
@@ -157,24 +157,14 @@ class PropertiesModal extends React.PureComponent {
}
// 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),
}),
{},
);
if (updateMetadata) {
jsonMetadataObj.color_scheme = colorScheme;
jsonMetadataObj.label_colors = jsonMetadataObj.label_colors || {};
this.onMetadataChange(jsonStringify(jsonMetadataObj));
}
this.updateFormState('colorScheme', value);
this.updateFormState('colorScheme', colorScheme);
}
onOwnersChange(value) {
@@ -261,21 +251,22 @@ class PropertiesModal extends React.PureComponent {
roles: rolesValue,
},
} = this.state;
const { onlyApply } = this.props;
const owners = ownersValue?.map(o => o.value) ?? [];
const roles = rolesValue?.map(o => o.value) ?? [];
let metadataColorScheme;
let currentColorScheme = colorScheme;
// update color scheme to match metadata
// color scheme in json metadata has precedence over selection
if (jsonMetadata?.length) {
const { color_scheme: metadataColorScheme } = JSON.parse(jsonMetadata);
if (metadataColorScheme) {
this.onColorSchemeChange(metadataColorScheme, {
updateMetadata: false,
});
}
const metadata = JSON.parse(jsonMetadata);
currentColorScheme = metadata?.color_scheme || colorScheme;
}
this.onColorSchemeChange(currentColorScheme, {
updateMetadata: false,
});
const moreProps = {};
const morePutProps = {};
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
@@ -289,7 +280,7 @@ class PropertiesModal extends React.PureComponent {
slug,
jsonMetadata,
ownerIds: owners,
colorScheme: metadataColorScheme || colorScheme,
colorScheme: currentColorScheme,
...moreProps,
});
this.props.onHide();
@@ -316,7 +307,7 @@ class PropertiesModal extends React.PureComponent {
slug: result.slug,
jsonMetadata: result.json_metadata,
ownerIds: result.owners,
colorScheme: metadataColorScheme || colorScheme,
colorScheme: currentColorScheme,
...moreResultProps,
});
this.props.onHide();

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';
@@ -131,11 +131,11 @@ class SaveModal extends React.PureComponent<SaveModalProps, SaveModalState> {
lastModifiedTime,
} = this.props;
const scale = CategoricalColorNamespace.getScale(
colorScheme,
colorNamespace,
);
const labelColors = colorScheme ? scale.getColorMap() : {};
const labelColors =
colorScheme && dashboardInfo?.metadata?.label_colors
? dashboardInfo.metadata.label_colors
: {};
// check refresh frequency is for current session or persist
const refreshFrequency = shouldPersistRefreshFrequency
? currentRefreshFrequency

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

@@ -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 {
@@ -53,6 +58,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 +120,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,6 +135,33 @@ 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() {

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}

View File

@@ -591,6 +591,7 @@ function mapStateToProps(state) {
);
const chartKey = Object.keys(charts)[0];
const chart = charts[chartKey];
return {
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
datasource: explore.datasource,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import Modal from 'src/components/Modal';
import { Row, Col, Input, TextArea } from 'src/common/components';
import Button from 'src/components/Button';
@@ -33,6 +33,8 @@ type PropertiesModalProps = {
show: boolean;
onHide: () => void;
onSave: (chart: Chart) => void;
permissionsError?: string;
existingOwners?: SelectValue;
};
export default function PropertiesModal({
@@ -42,16 +44,15 @@ export default function PropertiesModal({
show,
}: PropertiesModalProps) {
const [submitting, setSubmitting] = useState(false);
const [selectedOwners, setSelectedOwners] = useState<SelectValue | null>(
null,
);
// values of form inputs
const [name, setName] = useState(slice.slice_name || '');
const [description, setDescription] = useState(slice.description || '');
const [cacheTimeout, setCacheTimeout] = useState(
slice.cache_timeout != null ? slice.cache_timeout : '',
);
const [selectedOwners, setSelectedOwners] = useState<SelectValue | null>(
null,
);
function showError({ error, statusText, message }: any) {
let errorText = error || statusText || t('An error has occurred');
@@ -65,8 +66,8 @@ export default function PropertiesModal({
});
}
const fetchChartData = useCallback(
async function fetchChartData() {
const fetchChartOwners = useCallback(
async function fetchChartOwners() {
try {
const response = await SupersetClient.get({
endpoint: `/api/v1/chart/${slice.slice_id}`,
@@ -143,8 +144,8 @@ export default function PropertiesModal({
// get the owners of this slice
useEffect(() => {
fetchChartData();
}, [fetchChartData]);
fetchChartOwners();
}, [fetchChartOwners]);
// update name after it's changed in another modal
useEffect(() => {
@@ -241,8 +242,8 @@ export default function PropertiesModal({
mode="multiple"
name="owners"
value={selectedOwners || []}
options={loadOptions}
onChange={setSelectedOwners}
options={loadOptions}
disabled={!selectedOwners}
allowClear
/>

View File

@@ -90,7 +90,7 @@ class SaveModal extends React.Component<SaveModalProps, SaveModalState> {
const lastDashboard = sessionStorage.getItem(SK_DASHBOARD_ID);
let recentDashboard = lastDashboard && parseInt(lastDashboard, 10);
if (!recentDashboard && this.props.dashboardId) {
if (this.props.dashboardId) {
recentDashboard = this.props.dashboardId;
}

View File

@@ -1,54 +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 PropTypes from 'prop-types';
import React from 'react';
import { CategoricalColorNamespace } from '@superset-ui/core';
const propTypes = {
onChange: PropTypes.func,
value: PropTypes.object,
colorScheme: PropTypes.string,
colorNamespace: PropTypes.string,
};
const defaultProps = {
onChange: () => {},
value: {},
colorScheme: undefined,
colorNamespace: undefined,
};
export default class ColorMapControl extends React.PureComponent {
constructor(props) {
super(props);
Object.keys(this.props.value).forEach(label => {
CategoricalColorNamespace.getScale(
this.props.colorScheme,
this.props.colorNamespace,
).setColor(label, this.props.value[label]);
});
}
render() {
return null;
}
}
ColorMapControl.propTypes = propTypes;
ColorMapControl.defaultProps = defaultProps;

View File

@@ -110,23 +110,31 @@ export default class ColorSchemeControl extends React.PureComponent {
// save parsed schemes for later
this.schemes = isFunction(schemes) ? schemes() : schemes;
const options = (isFunction(choices) ? choices() : choices).map(
([value]) => ({
value,
label: this.schemes?.[value]?.label || value,
customLabel: this.renderOption(value),
}),
const allColorOptions = (isFunction(choices) ? choices() : choices).filter(
o => o[0] !== 'SUPERSET_DEFAULT',
);
const options = allColorOptions.map(([value]) => ({
value,
label: this.schemes?.[value]?.label || value,
customLabel: this.renderOption(value),
}));
let currentScheme =
this.props.value ||
(this.props.default !== undefined ? this.props.default : undefined);
if (currentScheme === 'SUPERSET_DEFAULT') {
currentScheme = this.schemes?.SUPERSET_DEFAULT?.id;
}
const selectProps = {
ariaLabel: t('Select color scheme'),
allowClear: this.props.clearable,
defaultValue: this.props.default,
name: `select-${this.props.name}`,
onChange: this.onChange,
options,
placeholder: `Select (${options.length})`,
showSearch: true,
value: this.props.value,
value: currentScheme,
};
return (
<Select header={<ControlHeader {...this.props} />} {...selectProps} />

View File

@@ -41,6 +41,7 @@ const createProps = () => ({
name: 'channels',
type: 'table',
columns: [],
owners: [{ first_name: 'john', last_name: 'doe', id: 1, username: 'jd' }],
},
validationErrors: [],
name: 'datasource',

View File

@@ -19,14 +19,44 @@
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import { DndMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
import { AGGREGATES } from 'src/explore/constants';
import { EXPRESSION_TYPES } from '../MetricControl/AdhocMetric';
const defaultProps = {
savedMetrics: [
{
metric_name: 'Metric A',
expression: 'Expression A',
metric_name: 'metric_a',
expression: 'expression_a',
},
{
metric_name: 'metric_b',
expression: 'expression_b',
verbose_name: 'Metric B',
},
],
columns: [
{
column_name: 'column_a',
},
{
column_name: 'column_b',
verbose_name: 'Column B',
},
],
onChange: () => {},
};
const adhocMetricA = {
expressionType: EXPRESSION_TYPES.SIMPLE,
column: defaultProps.columns[0],
aggregate: AGGREGATES.SUM,
optionName: 'abc',
};
const adhocMetricB = {
expressionType: EXPRESSION_TYPES.SIMPLE,
column: defaultProps.columns[1],
aggregate: AGGREGATES.SUM,
optionName: 'def',
};
test('renders with default props', () => {
@@ -38,3 +68,161 @@ test('renders with default props and multi = true', () => {
render(<DndMetricSelect {...defaultProps} multi />, { useDnd: true });
expect(screen.getByText('Drop columns or metrics here')).toBeInTheDocument();
});
test('render selected metrics correctly', () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDnd: true,
});
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
expect(screen.getByText('SUM(Column B)')).toBeVisible();
});
test('remove selected custom metric when metric gets removed from dataset', () => {
let metricValues = ['metric_a', 'metric_b', adhocMetricA, adhocMetricB];
const onChange = (val: any[]) => {
metricValues = val;
};
const { rerender } = render(
<DndMetricSelect
{...defaultProps}
value={metricValues}
onChange={onChange}
multi
/>,
{
useDnd: true,
},
);
const newPropsWithRemovedMetric = {
...defaultProps,
savedMetrics: [
{
metric_name: 'metric_a',
expression: 'expression_a',
},
],
};
rerender(
<DndMetricSelect
{...newPropsWithRemovedMetric}
value={metricValues}
onChange={onChange}
multi
/>,
);
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.queryByText('Metric B')).not.toBeInTheDocument();
expect(screen.getByText('SUM(column_a)')).toBeVisible();
expect(screen.getByText('SUM(Column B)')).toBeVisible();
});
test('remove selected adhoc metric when column gets removed from dataset', async () => {
let metricValues = ['metric_a', 'metric_b', adhocMetricA, adhocMetricB];
const onChange = (val: any[]) => {
metricValues = val;
};
const { rerender } = render(
<DndMetricSelect
{...defaultProps}
value={metricValues}
onChange={onChange}
multi
/>,
{
useDnd: true,
},
);
const newPropsWithRemovedColumn = {
...defaultProps,
columns: [
{
column_name: 'column_a',
},
],
};
// rerender twice - first to update columns, second to update value
rerender(
<DndMetricSelect
{...newPropsWithRemovedColumn}
value={metricValues}
onChange={onChange}
multi
/>,
);
rerender(
<DndMetricSelect
{...newPropsWithRemovedColumn}
value={metricValues}
onChange={onChange}
multi
/>,
);
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
expect(screen.getByText('SUM(column_a)')).toBeVisible();
expect(screen.queryByText('SUM(Column B)')).not.toBeInTheDocument();
});
test('update adhoc metric name when column label in dataset changes', () => {
let metricValues = ['metric_a', 'metric_b', adhocMetricA, adhocMetricB];
const onChange = (val: any[]) => {
metricValues = val;
};
const { rerender } = render(
<DndMetricSelect
{...defaultProps}
value={metricValues}
onChange={onChange}
multi
/>,
{
useDnd: true,
},
);
const newPropsWithUpdatedColNames = {
...defaultProps,
columns: [
{
column_name: 'column_a',
verbose_name: 'new col A name',
},
{
column_name: 'column_b',
verbose_name: 'new col B name',
},
],
};
// rerender twice - first to update columns, second to update value
rerender(
<DndMetricSelect
{...newPropsWithUpdatedColNames}
value={metricValues}
onChange={onChange}
multi
/>,
);
rerender(
<DndMetricSelect
{...newPropsWithUpdatedColNames}
value={metricValues}
onChange={onChange}
multi
/>,
);
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
expect(screen.getByText('SUM(new col A name)')).toBeVisible();
expect(screen.getByText('SUM(new col B name)')).toBeVisible();
});

View File

@@ -19,7 +19,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
DatasourceType,
ensureIsArray,
FeatureFlag,
GenericDataType,
@@ -42,7 +41,6 @@ import { DndItemType } from 'src/explore/components/DndItemType';
import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
import { savedMetricType } from 'src/explore/components/controls/MetricControl/types';
import { AGGREGATES } from 'src/explore/constants';
import { DndControlProps } from './types';
const EMPTY_OBJECT = {};
const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric];
@@ -82,39 +80,48 @@ const getOptionsForSavedMetrics = (
type ValueType = Metric | AdhocMetric | QueryFormMetric;
const columnsContainAllMetrics = (
value: ValueType | ValueType[] | null | undefined,
// TODO: use typeguards to distinguish saved metrics from adhoc metrics
const getMetricsMatchingCurrentDataset = (
values: ValueType[],
columns: ColumnMeta[],
savedMetrics: (savedMetricType | Metric)[],
prevColumns: ColumnMeta[],
prevSavedMetrics: (savedMetricType | Metric)[],
) => {
const columnNames = new Set(
[...(columns || []), ...(savedMetrics || [])]
// eslint-disable-next-line camelcase
.map(
item =>
(item as ColumnMeta).column_name ||
(item as savedMetricType).metric_name,
),
);
const areSavedMetricsEqual =
!prevSavedMetrics || isEqual(prevSavedMetrics, savedMetrics);
const areColsEqual = !prevColumns || isEqual(prevColumns, columns);
return (
ensureIsArray(value)
.filter(metric => metric)
// find column names
.map(metric =>
(metric as AdhocMetric).column
? (metric as AdhocMetric).column.column_name
: (metric as ColumnMeta).column_name || metric,
)
.filter(name => name && typeof name === 'string')
.every(name => columnNames.has(name))
);
};
if (areColsEqual && areSavedMetricsEqual) {
return values;
}
return values.reduce((acc: ValueType[], metric) => {
if (
(typeof metric === 'string' || (metric as Metric).metric_name) &&
(areSavedMetricsEqual ||
savedMetrics?.some(
savedMetric =>
savedMetric.metric_name === metric ||
savedMetric.metric_name === (metric as Metric).metric_name,
))
) {
acc.push(metric);
return acc;
}
export type DndMetricSelectProps = DndControlProps<ValueType> & {
savedMetrics: savedMetricType[];
columns: ColumnMeta[];
datasourceType?: DatasourceType;
if (!areColsEqual) {
const newCol = columns?.find(
column =>
(metric as AdhocMetric).column?.column_name === column.column_name,
);
if (newCol) {
acc.push({ ...(metric as AdhocMetric), column: newCol });
}
} else {
acc.push(metric);
}
return acc;
}, []);
};
export const DndMetricSelect = (props: any) => {
@@ -158,25 +165,25 @@ export const DndMetricSelect = (props: any) => {
}, [JSON.stringify(props.value)]);
useEffect(() => {
if (
!isEqual(prevColumns, columns) ||
!isEqual(prevSavedMetrics, savedMetrics)
) {
// Remove all metrics if selected value no longer a valid column
// in the dataset. Must use `nextProps` here because Redux reducers may
// have already updated the value for this control.
if (!columnsContainAllMetrics(props.value, columns, savedMetrics)) {
onChange([]);
}
// Remove selected custom metrics that do not exist in the dataset anymore
// Remove selected adhoc metrics that use columns which do not exist in the dataset anymore
// Sync adhoc metrics with dataset columns when they are modified by the user
if (!props.value) {
return;
}
}, [
prevColumns,
columns,
prevSavedMetrics,
savedMetrics,
props.value,
onChange,
]);
const propsValues = ensureIsArray(props.value);
const matchingMetrics = getMetricsMatchingCurrentDataset(
propsValues,
columns,
savedMetrics,
prevColumns,
prevSavedMetrics,
);
if (!isEqual(propsValues, matchingMetrics)) {
handleChange(matchingMetrics);
}
}, [columns, savedMetrics, handleChange]);
const canDrop = useCallback(
(item: DatasourcePanelDndItem) => {
@@ -337,7 +344,7 @@ export const DndMetricSelect = (props: any) => {
) {
const itemValue = droppedItem.value as ColumnMeta;
const config: Partial<AdhocMetric> = {
column: { column_name: itemValue?.column_name },
column: itemValue,
};
if (isFeatureEnabled(FeatureFlag.UX_BETA)) {
if (itemValue.type_generic === GenericDataType.NUMERIC) {

View File

@@ -59,6 +59,8 @@ const selectedMetricType = PropTypes.oneOfType([
const propTypes = {
label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
name: PropTypes.string,
sections: PropTypes.arrayOf(PropTypes.string),
operators: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func,
value: PropTypes.arrayOf(adhocFilterType),
datasource: PropTypes.object,
@@ -107,6 +109,8 @@ class AdhocFilterControl extends React.Component {
adhocFilter={adhocFilter}
onFilterEdit={this.onFilterEdit}
options={this.state.options}
sections={this.props.sections}
operators={this.props.operators}
datasource={this.props.datasource}
onRemoveFilter={() => this.onRemoveFilter(index)}
onMoveLabel={this.moveLabel}
@@ -324,6 +328,8 @@ class AdhocFilterControl extends React.Component {
addNewFilterPopoverTrigger(trigger) {
return (
<AdhocFilterPopoverTrigger
operators={this.props.operators}
sections={this.props.sections}
adhocFilter={new AdhocFilter({})}
datasource={this.props.datasource}
options={this.state.options}

View File

@@ -46,6 +46,8 @@ const propTypes = {
datasource: PropTypes.object,
partitionColumn: PropTypes.string,
theme: PropTypes.object,
sections: PropTypes.arrayOf(PropTypes.string),
operators: PropTypes.arrayOf(PropTypes.string),
};
const ResizeIcon = styled.i`
@@ -54,6 +56,11 @@ const ResizeIcon = styled.i`
const startingWidth = 320;
const startingHeight = 240;
const SectionWrapper = styled.div`
.ant-select {
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
}
`;
const FilterPopoverContentContainer = styled.div`
.adhoc-filter-edit-tabs > .nav-tabs {
@@ -166,15 +173,51 @@ export default class AdhocFilterEditPopover extends React.Component {
onResize,
datasource,
partitionColumn,
sections = ['SIMPLE', 'CUSTOM_SQL'],
theme,
operators,
...popoverProps
} = this.props;
const { adhocFilter } = this.state;
const resultSections =
datasource?.type === 'druid'
? sections.filter(s => s !== 'CUSTOM_SQL')
: sections;
const stateIsValid = adhocFilter.isValid();
const hasUnsavedChanges = !adhocFilter.equals(propsAdhocFilter);
const sectionRenders = {};
sectionRenders.CUSTOM_SQL = (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
activeKey={this.state.activeKey}
/>
</ErrorBoundary>
);
sectionRenders.SIMPLE = (
<ErrorBoundary>
<AdhocFilterEditPopoverSimpleTabContent
operators={operators}
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={options}
datasource={datasource}
onHeightChange={this.adjustHeight}
partitionColumn={partitionColumn}
popoverRef={this.popoverContentRef.current}
/>
</ErrorBoundary>
);
return (
<FilterPopoverContentContainer
id="filter-edit-popover"
@@ -182,55 +225,38 @@ export default class AdhocFilterEditPopover extends React.Component {
data-test="filter-edit-popover"
ref={this.popoverContentRef}
>
<Tabs
id="adhoc-filter-edit-tabs"
defaultActiveKey={adhocFilter.expressionType}
className="adhoc-filter-edit-tabs"
data-test="adhoc-filter-edit-tabs"
style={{ minHeight: this.state.height, width: this.state.width }}
allowOverflow
onChange={this.onTabChange}
>
<Tabs.TabPane
className="adhoc-filter-edit-tab"
key={EXPRESSION_TYPES.SIMPLE}
tab={t('Simple')}
{resultSections.length > 1 ? (
<Tabs
id="adhoc-filter-edit-tabs"
defaultActiveKey={adhocFilter.expressionType}
className="adhoc-filter-edit-tabs"
data-test="adhoc-filter-edit-tabs"
style={{ minHeight: this.state.height, width: this.state.width }}
allowOverflow
onChange={this.onTabChange}
>
<ErrorBoundary>
<AdhocFilterEditPopoverSimpleTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={options}
datasource={datasource}
onHeightChange={this.adjustHeight}
partitionColumn={partitionColumn}
popoverRef={this.popoverContentRef.current}
/>
</ErrorBoundary>
</Tabs.TabPane>
<Tabs.TabPane
className="adhoc-filter-edit-tab"
key={EXPRESSION_TYPES.SQL}
tab={t('Custom SQL')}
>
<ErrorBoundary>
{!this.props.datasource ||
this.props.datasource.type !== 'druid' ? (
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
activeKey={this.state.activeKey}
/>
) : (
<div className="custom-sql-disabled-message">
Custom SQL Filters are not available on druid datasources
</div>
)}
</ErrorBoundary>
</Tabs.TabPane>
</Tabs>
{resultSections.includes('SIMPLE') && (
<Tabs.TabPane
className="adhoc-filter-edit-tab"
key={EXPRESSION_TYPES.SIMPLE}
tab={t('Simple')}
>
{sectionRenders.SIMPLE}
</Tabs.TabPane>
)}
{resultSections.includes('CUSTOM_SQL') && (
<Tabs.TabPane
className="adhoc-filter-edit-tab"
key={EXPRESSION_TYPES.SQL}
tab={t('Custom SQL')}
>
{sectionRenders.CUSTOM_SQL}
</Tabs.TabPane>
)}
</Tabs>
) : (
<SectionWrapper>{sectionRenders[resultSections[0]]}</SectionWrapper>
)}
<div>
<Button buttonSize="small" onClick={this.props.onClose} cta>
{t('Close')}

View File

@@ -38,6 +38,20 @@ import AdhocFilter, {
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { Input } from 'src/common/components';
const StyledInput = styled(Input)`
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
`;
const SelectWithLabel = styled(Select)<{ labelText: string }>`
.ant-select-selector::after {
content: ${({ labelText }) => labelText || '\\A0'};
display: inline-block;
white-space: nowrap;
color: ${({ theme }) => theme.colors.grayscale.light1};
width: max-content;
}
`;
export interface SimpleColumnType {
id: number;
column_name: string;
@@ -81,6 +95,7 @@ export interface Props {
filter_select: boolean;
};
partitionColumn: string;
operators?: Operators[];
}
export const useSimpleTabFilterProps = (props: Props) => {
const isOperatorRelevant = (operator: Operators, subject: string) => {
@@ -280,7 +295,9 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
const operatorSelectProps = {
placeholder: t(
'%s operator(s)',
OPERATORS_OPTIONS.filter(op => isOperatorRelevant(op, subject)).length,
(props.operators ?? OPERATORS_OPTIONS).filter(op =>
isOperatorRelevant(op, subject),
).length,
),
value: operatorId,
onChange: onOperatorChange,
@@ -310,16 +327,6 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
const labelText =
comparator && comparator.length > 0 && createSuggestionsPlaceholder();
const SelectWithLabel = styled(Select)`
.ant-select-selector::after {
content: ${() => labelText || '\\A0'};
display: inline-block;
white-space: nowrap;
color: ${({ theme }) => theme.colors.grayscale.light1};
width: max-content;
}
`;
useEffect(() => {
const refreshComparatorSuggestions = () => {
const { datasource } = props;
@@ -380,17 +387,18 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
/>
<Select
css={theme => ({ marginBottom: theme.gridUnit * 4 })}
options={OPERATORS_OPTIONS.filter(op =>
isOperatorRelevant(op, subject),
).map(option => ({
value: option,
label: OPERATOR_ENUM_TO_OPERATOR_TYPE[option].display,
key: option,
}))}
options={(props.operators ?? OPERATORS_OPTIONS)
.filter(op => isOperatorRelevant(op, subject))
.map(option => ({
value: option,
label: OPERATOR_ENUM_TO_OPERATOR_TYPE[option].display,
key: option,
}))}
{...operatorSelectProps}
/>
{MULTI_OPERATORS.has(operatorId) || suggestions.length > 0 ? (
<SelectWithLabel
labelText={labelText}
options={suggestions.map((suggestion: string) => ({
value: suggestion,
label: String(suggestion),
@@ -398,7 +406,7 @@ const AdhocFilterEditPopoverSimpleTabContent: React.FC<Props> = props => {
{...comparatorSelectProps}
/>
) : (
<Input
<StyledInput
data-test="adhoc-filter-simple-value"
name="filter-value"
ref={ref => {

View File

@@ -36,6 +36,8 @@ const propTypes = {
adhocMetricType,
]),
).isRequired,
sections: PropTypes.arrayOf(PropTypes.string),
operators: PropTypes.arrayOf(PropTypes.string),
datasource: PropTypes.object,
partitionColumn: PropTypes.string,
onMoveLabel: PropTypes.func,
@@ -53,8 +55,12 @@ const AdhocFilterOption = ({
onMoveLabel,
onDropLabel,
index,
sections,
operators,
}) => (
<AdhocFilterPopoverTrigger
sections={sections}
operators={operators}
adhocFilter={adhocFilter}
options={options}
datasource={datasource}

View File

@@ -22,8 +22,11 @@ import { OptionSortType } from 'src/explore/types';
import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { ExplorePopoverContent } from 'src/explore/components/ExploreContentPopover';
import { Operators } from 'src/explore/constants';
interface AdhocFilterPopoverTriggerProps {
sections?: string[];
operators?: Operators[];
adhocFilter: AdhocFilter;
options: OptionSortType[];
datasource: Record<string, any>;
@@ -90,6 +93,8 @@ class AdhocFilterPopoverTrigger extends React.PureComponent<
partitionColumn={this.props.partitionColumn}
onResize={this.onPopoverResize}
onClose={closePopover}
sections={this.props.sections}
operators={this.props.operators}
onChange={this.props.onFilterEdit}
/>
</ExplorePopoverContent>

View File

@@ -86,17 +86,20 @@ export default class AdhocMetric {
}
getDefaultLabel() {
const label = this.translateToSql();
const label = this.translateToSql(true);
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
}
translateToSql() {
translateToSql(useVerboseName = false) {
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
const aggregate = this.aggregate || '';
// eslint-disable-next-line camelcase
const column = this.column?.column_name
? `(${this.column.column_name})`
: '';
const column =
useVerboseName && this.column?.verbose_name
? `(${this.column.verbose_name})`
: this.column?.column_name
? `(${this.column.column_name})`
: '';
return aggregate + column;
}
if (this.expressionType === EXPRESSION_TYPES.SQL) {

View File

@@ -19,6 +19,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { ensureIsArray, t, useTheme } from '@superset-ui/core';
import { isEqual } from 'lodash';
import ControlHeader from 'src/explore/components/ControlHeader';
import Icons from 'src/components/Icons';
import {
@@ -27,6 +28,7 @@ import {
HeaderContainer,
LabelsContainer,
} from 'src/explore/components/controls/OptionControls';
import { usePrevious } from 'src/common/hooks/usePrevious';
import columnType from './columnType';
import MetricDefinitionValue from './MetricDefinitionValue';
import AdhocMetric from './AdhocMetric';
@@ -75,27 +77,6 @@ function isDictionaryForAdhocMetric(value) {
return value && !(value instanceof AdhocMetric) && value.expressionType;
}
function columnsContainAllMetrics(value, columns, savedMetrics) {
const columnNames = new Set(
[...(columns || []), ...(savedMetrics || [])]
// eslint-disable-next-line camelcase
.map(({ column_name, metric_name }) => column_name || metric_name),
);
return (
(Array.isArray(value) ? value : [value])
.filter(metric => metric)
// find column names
.map(metric =>
metric.column
? metric.column.column_name
: metric.column_name || metric,
)
.filter(name => name && typeof name === 'string')
.every(name => columnNames.has(name))
);
}
// adhoc metrics are stored as dictionaries in URL params. We convert them back into the
// AdhocMetric class for typechecking, consistency and instance method access.
function coerceAdhocMetrics(value) {
@@ -118,6 +99,22 @@ function coerceAdhocMetrics(value) {
const emptySavedMetric = { metric_name: '', expression: '' };
// TODO: use typeguards to distinguish saved metrics from adhoc metrics
const getMetricsMatchingCurrentDataset = (value, columns, savedMetrics) =>
ensureIsArray(value).filter(metric => {
if (typeof metric === 'string' || metric.metric_name) {
return savedMetrics?.some(
savedMetric =>
savedMetric.metric_name === metric ||
savedMetric.metric_name === metric.metric_name,
);
}
return columns?.some(
column =>
!metric.column || metric.column.column_name === column.column_name,
);
});
const MetricsControl = ({
onChange,
multi,
@@ -130,6 +127,8 @@ const MetricsControl = ({
}) => {
const [value, setValue] = useState(coerceAdhocMetrics(propsValue));
const theme = useTheme();
const prevColumns = usePrevious(columns);
const prevSavedMetrics = usePrevious(savedMetrics);
const handleChange = useCallback(
opts => {
@@ -253,13 +252,23 @@ const MetricsControl = ({
);
useEffect(() => {
// Remove all metrics if selected value no longer a valid column
// in the dataset. Must use `nextProps` here because Redux reducers may
// have already updated the value for this control.
if (!columnsContainAllMetrics(propsValue, columns, savedMetrics)) {
handleChange([]);
// Remove selected custom metrics that do not exist in the dataset anymore
// Remove selected adhoc metrics that use columns which do not exist in the dataset anymore
if (
propsValue &&
(!isEqual(prevColumns, columns) ||
!isEqual(prevSavedMetrics, savedMetrics))
) {
const matchingMetrics = getMetricsMatchingCurrentDataset(
propsValue,
columns,
savedMetrics,
);
if (!isEqual(matchingMetrics, propsValue)) {
handleChange(matchingMetrics);
}
}
}, [columns, savedMetrics]);
}, [columns, handleChange, savedMetrics]);
useEffect(() => {
setValue(coerceAdhocMetrics(propsValue));

View File

@@ -17,19 +17,21 @@
* under the License.
*/
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import SelectAsyncControl from '.';
jest.mock('src/components/AsyncSelect', () => ({
const datasetsOwnersEndpoint = 'glob:*/api/v1/dataset/related/owners*';
jest.mock('src/components/Select/Select', () => ({
__esModule: true,
default: (props: any) => (
<div
data-test="select-test"
data-data-endpoint={props.dataEndpoint}
data-value={JSON.stringify(props.value)}
data-placeholder={props.placeholder}
data-multi={props.multi ? 'true' : 'false'}
data-multi={props.mode}
>
<button
type="button"
@@ -40,19 +42,19 @@ jest.mock('src/components/AsyncSelect', () => ({
<button type="button" onClick={() => props.mutator()}>
mutator
</button>
<div data-test="valueRenderer">
{props.valueRenderer({ label: 'valueRenderer' })}
</div>
</div>
),
}));
fetchMock.get(datasetsOwnersEndpoint, {
result: [],
});
const createProps = () => ({
ariaLabel: 'SelectAsyncControl',
value: [],
dataEndpoint: 'api/v1/dataset/related/owners',
dataEndpoint: datasetsOwnersEndpoint,
multi: true,
onAsyncErrorMessage: 'Error while fetching data',
placeholder: 'Select ...',
onChange: jest.fn(),
mutator: jest.fn(),
@@ -65,17 +67,13 @@ beforeEach(() => {
test('Should render', () => {
const props = createProps();
render(<SelectAsyncControl {...props} />, { useRedux: true });
expect(screen.getByTestId('SelectAsyncControl')).toBeInTheDocument();
expect(screen.getByTestId('select-test')).toBeInTheDocument();
});
test('Should send correct props to AsyncSelect component - value props', () => {
test('Should send correct props to Select component - value props', () => {
const props = createProps();
render(<SelectAsyncControl {...props} />, { useRedux: true });
expect(screen.getByTestId('select-test')).toHaveAttribute(
'data-data-endpoint',
props.dataEndpoint,
);
expect(screen.getByTestId('select-test')).toHaveAttribute(
'data-value',
JSON.stringify(props.value),
@@ -86,14 +84,11 @@ test('Should send correct props to AsyncSelect component - value props', () => {
);
expect(screen.getByTestId('select-test')).toHaveAttribute(
'data-multi',
'true',
);
expect(screen.getByTestId('valueRenderer')).toHaveTextContent(
'valueRenderer',
'multiple',
);
});
test('Should send correct props to AsyncSelect component - function onChange multi:true', () => {
test('Should send correct props to Select component - function onChange multi:true', () => {
const props = createProps();
render(<SelectAsyncControl {...props} />, { useRedux: true });
expect(props.onChange).toBeCalledTimes(0);
@@ -101,7 +96,7 @@ test('Should send correct props to AsyncSelect component - function onChange mul
expect(props.onChange).toBeCalledTimes(1);
});
test('Should send correct props to AsyncSelect component - function onChange multi:false', () => {
test('Should send correct props to Select component - function onChange multi:false', () => {
const props = createProps();
render(<SelectAsyncControl {...{ ...props, multi: false }} />, {
useRedux: true,

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