mirror of
https://github.com/apache/superset.git
synced 2026-05-02 06:24:37 +00:00
Compare commits
60 Commits
remove-mor
...
1.4.0rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
361e510cae | ||
|
|
eb64f82c20 | ||
|
|
3aa554b422 | ||
|
|
ed45717d5f | ||
|
|
6f932f9ec1 | ||
|
|
b577773e30 | ||
|
|
dfd0ed395c | ||
|
|
cca78f1b6e | ||
|
|
b4f4f4cdfa | ||
|
|
5899ae163a | ||
|
|
9459d09ae7 | ||
|
|
0b8507fe77 | ||
|
|
c015e661a9 | ||
|
|
7d9f63eda7 | ||
|
|
30c6ec07ef | ||
|
|
680cdca14a | ||
|
|
8a24374f9f | ||
|
|
33826cd50f | ||
|
|
c4b57e6b3e | ||
|
|
7db1caa2ad | ||
|
|
687676cb09 | ||
|
|
2eaf5c854e | ||
|
|
09b853c831 | ||
|
|
5f34ae2cf3 | ||
|
|
ea3d51efec | ||
|
|
55b3da661e | ||
|
|
b775193f9f | ||
|
|
7c966c52dc | ||
|
|
6da69a821f | ||
|
|
03481fe28d | ||
|
|
f467a7ca57 | ||
|
|
35d8a40634 | ||
|
|
5d43a5926c | ||
|
|
52ac5ecdbb | ||
|
|
e80c8ea980 | ||
|
|
43019a1b9d | ||
|
|
3f869aded2 | ||
|
|
f3bde45e5c | ||
|
|
cf4e129f5d | ||
|
|
23313fdb1f | ||
|
|
d90bfa65ef | ||
|
|
4e3fa1a141 | ||
|
|
35dda573cf | ||
|
|
5bfa6e96cd | ||
|
|
81da0fb466 | ||
|
|
c03771dc6d | ||
|
|
ed98027adc | ||
|
|
43e27b1137 | ||
|
|
bd5d787257 | ||
|
|
f56a3d8b1a | ||
|
|
3d77daaf68 | ||
|
|
19d2fef490 | ||
|
|
4d33f7b7b6 | ||
|
|
ddb35a3633 | ||
|
|
8755911765 | ||
|
|
012f6ac206 | ||
|
|
b2b3cd73fd | ||
|
|
6ed84f2d35 | ||
|
|
a6a3cedf6e | ||
|
|
083ff12864 |
2
.github/workflows/superset-e2e.yml
vendored
2
.github/workflows/superset-e2e.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/superset-frontend.yml
vendored
1
.github/workflows/superset-frontend.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/superset-helm-lint.yml
vendored
2
.github/workflows/superset-helm-lint.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
7
.github/workflows/superset-python-misc.yml
vendored
7
.github/workflows/superset-python-misc.yml
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
2
.github/workflows/superset-translations.yml
vendored
2
.github/workflows/superset-translations.yml
vendored
@@ -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
|
||||
|
||||
9151
CHANGELOG.md
9151
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
6
Makefile
6
Makefile
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
18
UPDATING.md
18
UPDATING.md
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
5
setup.py
5
setup.py
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
14174
superset-frontend/package-lock.json
generated
14174
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,7 +207,7 @@ div.Workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#brace-editor {
|
||||
#ace-editor {
|
||||
height: calc(100% - 51px);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
</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" />
|
||||
|
||||
</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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 || [],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ describe('FilterScope', () => {
|
||||
const mockedProps = {
|
||||
filterId: 'DefaultFilterId',
|
||||
restoreFilter: jest.fn(),
|
||||
setErroredFilters: jest.fn(),
|
||||
parentFilters: [],
|
||||
save,
|
||||
removedFilters: {},
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]() {
|
||||
|
||||
@@ -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'] });
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
#brace-editor {
|
||||
#ace-editor {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -591,6 +591,7 @@ function mapStateToProps(state) {
|
||||
);
|
||||
const chartKey = Object.keys(charts)[0];
|
||||
const chart = charts[chartKey];
|
||||
|
||||
return {
|
||||
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
|
||||
datasource: explore.datasource,
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user