mirror of
https://github.com/apache/superset.git
synced 2026-04-30 13:34:20 +00:00
Compare commits
90 Commits
prefer-bin
...
1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d29f661416 | ||
|
|
fbc81d7883 | ||
|
|
caf0cff6de | ||
|
|
6b868b0f44 | ||
|
|
24f4f67dde | ||
|
|
a222ba8e06 | ||
|
|
75d8006e2a | ||
|
|
20eaeae68f | ||
|
|
5045ff4d91 | ||
|
|
1e2b2f10f0 | ||
|
|
96c18b4272 | ||
|
|
8057582b02 | ||
|
|
70228ad3fb | ||
|
|
f4661d6924 | ||
|
|
398a01f7dc | ||
|
|
752062674b | ||
|
|
cda62508c4 | ||
|
|
2e0fe93269 | ||
|
|
4991272b9c | ||
|
|
ba6d5b9754 | ||
|
|
8b0ab83119 | ||
|
|
4c00bd4332 | ||
|
|
77c4f2cb11 | ||
|
|
3d8ce130ba | ||
|
|
9837feff19 | ||
|
|
9468bdf0c1 | ||
|
|
c42ff7972f | ||
|
|
46343a9dca | ||
|
|
9818bc5e7a | ||
|
|
733fc7494c | ||
|
|
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
|
||||
|
||||
@@ -27,7 +27,7 @@ repos:
|
||||
rev: v0.910
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-all]
|
||||
additional_dependencies: [types-all, types-redis]
|
||||
- repo: https://github.com/peterdemin/pip-compile-multi
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
|
||||
9175
CHANGELOG.md
9175
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`:
|
||||
|
||||
|
||||
@@ -730,13 +730,13 @@
|
||||
"PT5M",
|
||||
"PT10M",
|
||||
"PT15M",
|
||||
"PT0.5H",
|
||||
"PT30M",
|
||||
"PT1H",
|
||||
"PT6H",
|
||||
"P1D",
|
||||
"P1W",
|
||||
"P1M",
|
||||
"P0.25Y",
|
||||
"P3M",
|
||||
"P1Y",
|
||||
"1969-12-28T00:00:00Z/P1W",
|
||||
"1969-12-29T00:00:00Z/P1W",
|
||||
@@ -998,13 +998,13 @@
|
||||
"PT5M",
|
||||
"PT10M",
|
||||
"PT15M",
|
||||
"PT0.5H",
|
||||
"PT30M",
|
||||
"PT1H",
|
||||
"PT6H",
|
||||
"P1D",
|
||||
"P1W",
|
||||
"P1M",
|
||||
"P0.25Y",
|
||||
"P3M",
|
||||
"P1Y",
|
||||
"1969-12-28T00:00:00Z/P1W",
|
||||
"1969-12-29T00:00:00Z/P1W",
|
||||
@@ -2722,7 +2722,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"impersonate_user": {
|
||||
"description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
|
||||
"description": "If Presto, Trino or Drill all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"parameters": {
|
||||
@@ -2816,7 +2816,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"impersonate_user": {
|
||||
"description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
|
||||
"description": "If Presto, Trino or Drill all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"parameters": {
|
||||
@@ -2866,7 +2866,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"impersonate_user": {
|
||||
"description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
|
||||
"description": "If Presto, Trino or Drill all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"parameters": {
|
||||
@@ -2914,7 +2914,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"impersonate_user": {
|
||||
"description": "If Presto, all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
|
||||
"description": "If Presto, Trino or Drill all the queries in SQL Lab are going to be executed as the currently logged on user who must have permission to run them.<br/>If Hive and hive.server2.enable.doAs is enabled, will run the queries as service account, but impersonate the currently logged on user via hive.server2.proxy.user property.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
@@ -19,3 +19,7 @@
|
||||
pyrsistent>=0.16.1,<0.17
|
||||
zipp==3.4.1
|
||||
sasl==0.3.1
|
||||
packaging==21.0
|
||||
wrapt==1.12.1
|
||||
certifi==2021.5.30
|
||||
charset-normalizer==2.0.4
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SHA1:04efc15075d69b1a2b5fa6c76b84c77a2f5c04e3
|
||||
# SHA1:fe363b0ea02d7589c2ba5a1cf936247a966a6d5e
|
||||
#
|
||||
# This file is autogenerated by pip-compile-multi
|
||||
# To update, run:
|
||||
@@ -35,10 +35,18 @@ cachelib==0.1.1
|
||||
# via apache-superset
|
||||
celery==4.4.7
|
||||
# via apache-superset
|
||||
certifi==2021.5.30
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
cffi==1.14.6
|
||||
# via cryptography
|
||||
chardet==4.0.0
|
||||
# via aiohttp
|
||||
charset-normalizer==2.0.4
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
click==7.1.2
|
||||
# via
|
||||
# apache-superset
|
||||
@@ -77,7 +85,7 @@ flask==1.1.4
|
||||
# flask-openid
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask-appbuilder==3.3.3
|
||||
flask-appbuilder==3.4.3
|
||||
# via apache-superset
|
||||
flask-babel==1.0.0
|
||||
# via flask-appbuilder
|
||||
@@ -109,7 +117,7 @@ geopy==2.2.0
|
||||
# via apache-superset
|
||||
graphlib-backport==1.0.3
|
||||
# via apache-superset
|
||||
gunicorn==20.0.4
|
||||
gunicorn==20.1.0
|
||||
# via apache-superset
|
||||
holidays==0.10.3
|
||||
# via apache-superset
|
||||
@@ -118,6 +126,7 @@ humanize==3.11.0
|
||||
idna==3.2
|
||||
# via
|
||||
# email-validator
|
||||
# requests
|
||||
# yarl
|
||||
isodate==0.6.0
|
||||
# via apache-superset
|
||||
@@ -166,6 +175,7 @@ numpy==1.21.1
|
||||
# pyarrow
|
||||
packaging==21.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# bleach
|
||||
# deprecation
|
||||
pandas==1.2.5
|
||||
@@ -226,6 +236,8 @@ pyyaml==5.4.1
|
||||
# apispec
|
||||
redis==3.5.3
|
||||
# via apache-superset
|
||||
requests==2.26.0
|
||||
# via apache-superset
|
||||
sasl==0.3.1
|
||||
# via -r requirements/base.in
|
||||
selenium==3.141.0
|
||||
@@ -257,7 +269,7 @@ sqlalchemy==1.3.24
|
||||
# flask-sqlalchemy
|
||||
# marshmallow-sqlalchemy
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-utils==0.36.8
|
||||
sqlalchemy-utils==0.37.8
|
||||
# via
|
||||
# apache-superset
|
||||
# flask-appbuilder
|
||||
@@ -270,7 +282,9 @@ typing-extensions==3.10.0.0
|
||||
# aiohttp
|
||||
# apache-superset
|
||||
urllib3==1.26.6
|
||||
# via selenium
|
||||
# via
|
||||
# requests
|
||||
# selenium
|
||||
vine==1.3.0
|
||||
# via
|
||||
# amqp
|
||||
@@ -281,6 +295,8 @@ werkzeug==1.0.1
|
||||
# via
|
||||
# flask
|
||||
# flask-jwt-extended
|
||||
wrapt==1.12.1
|
||||
# via -r requirements/base.in
|
||||
wtforms==2.3.3
|
||||
# via
|
||||
# flask-wtf
|
||||
|
||||
@@ -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:
|
||||
@@ -16,10 +16,6 @@ botocore==1.21.19
|
||||
# s3transfer
|
||||
cached-property==1.5.2
|
||||
# via tableschema
|
||||
certifi==2021.5.30
|
||||
# via requests
|
||||
charset-normalizer==2.0.4
|
||||
# via requests
|
||||
et-xmlfile==1.1.0
|
||||
# via openpyxl
|
||||
flask-cors==3.0.10
|
||||
@@ -40,7 +36,7 @@ mysqlclient==1.4.2.post1
|
||||
# via -r requirements/development.in
|
||||
openpyxl==3.0.7
|
||||
# via tabulator
|
||||
pillow==7.2.0
|
||||
pillow==8.3.1
|
||||
# via -r requirements/development.in
|
||||
progress==1.6
|
||||
# via -r requirements/development.in
|
||||
@@ -54,11 +50,6 @@ pyhive[hive]==0.6.4
|
||||
# via -r requirements/development.in
|
||||
pyinstrument==4.0.2
|
||||
# via -r requirements/development.in
|
||||
requests==2.26.0
|
||||
# via
|
||||
# pydruid
|
||||
# tableschema
|
||||
# tabulator
|
||||
rfc3986==1.5.0
|
||||
# via tableschema
|
||||
s3transfer==0.5.0
|
||||
|
||||
@@ -20,3 +20,4 @@ tox
|
||||
py>=1.10.0
|
||||
click==7.1.2
|
||||
packaging==21.0
|
||||
pyparsing==2.4.7
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SHA1:17ab2346746deadfc557e1df96014e77c8337f4b
|
||||
# SHA1:32bae3a7c758a411c20c86ff4d5bff825be46314
|
||||
#
|
||||
# This file is autogenerated by pip-compile-multi
|
||||
# To update, run:
|
||||
@@ -45,7 +45,9 @@ py==1.10.0
|
||||
# -r requirements/integration.in
|
||||
# tox
|
||||
pyparsing==2.4.7
|
||||
# via packaging
|
||||
# via
|
||||
# -r requirements/integration.in
|
||||
# packaging
|
||||
pyyaml==5.4.1
|
||||
# via pre-commit
|
||||
six==1.16.0
|
||||
|
||||
@@ -92,8 +92,6 @@ wcwidth==0.2.5
|
||||
# via prompt-toolkit
|
||||
websocket-client==1.2.0
|
||||
# via docker
|
||||
wrapt==1.12.1
|
||||
# via astroid
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# pip
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
#
|
||||
set -e
|
||||
|
||||
# Temporary fix, probably related with https://bugs.launchpad.net/ubuntu/+source/opencv/+bug/1890170
|
||||
# MySQL was failling with:
|
||||
# from . import _mysql
|
||||
# ImportError: /lib/x86_64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block
|
||||
export LD_PRELOAD=/lib/x86_64-linux-gnu/libstdc++.so.6
|
||||
export SUPERSET_CONFIG=${SUPERSET_CONFIG:-tests.integration_tests.superset_test_config}
|
||||
export SUPERSET_TESTENV=true
|
||||
echo "Superset config module: $SUPERSET_CONFIG"
|
||||
|
||||
8
setup.py
8
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.4.3, <4.0.0",
|
||||
"flask-caching>=1.10.0",
|
||||
"flask-compress",
|
||||
"flask-talisman",
|
||||
@@ -83,7 +83,7 @@ setup(
|
||||
"flask-wtf",
|
||||
"geopy",
|
||||
"graphlib-backport",
|
||||
"gunicorn>=20.0.2, <20.1",
|
||||
"gunicorn>=20.1.0",
|
||||
"holidays==0.10.3", # PINNED! https://github.com/dr-prodigy/python-holidays/issues/406
|
||||
"humanize",
|
||||
"itsdangerous>=1.0.0, <2.0.0", # https://github.com/apache/superset/pull/14627
|
||||
@@ -102,11 +102,12 @@ setup(
|
||||
"pyyaml>=5.4",
|
||||
"PyJWT>=1.7.1, <2",
|
||||
"redis",
|
||||
"requests==2.26.0",
|
||||
"selenium>=3.141.0",
|
||||
"simplejson>=3.15.0",
|
||||
"slackclient==2.5.0", # PINNED! slack changes file upload api in the future versions
|
||||
"sqlalchemy>=1.3.16, <1.4, !=1.3.21",
|
||||
"sqlalchemy-utils>=0.36.6, <0.37",
|
||||
"sqlalchemy-utils>=0.37.8, <0.38",
|
||||
"sqlparse==0.3.0", # PINNED! see https://github.com/andialbrecht/sqlparse/issues/562
|
||||
"tabulate==0.8.9",
|
||||
"typing-extensions>=3.10, <4", # needed to support Literal (3.8) and TypeGuard (3.10)
|
||||
@@ -169,5 +170,6 @@ setup(
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -24,10 +24,10 @@ import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper';
|
||||
|
||||
function selectColorScheme(color: string) {
|
||||
// open color scheme dropdown
|
||||
cy.get('.modal-body')
|
||||
.contains('Color Scheme')
|
||||
cy.get('.ant-modal-body')
|
||||
.contains('Color scheme')
|
||||
.parents('.ControlHeader')
|
||||
.next('.Select')
|
||||
.next('.ant-select')
|
||||
.click()
|
||||
.then($colorSelect => {
|
||||
// select a new color scheme
|
||||
@@ -37,7 +37,7 @@ function selectColorScheme(color: string) {
|
||||
|
||||
function assertMetadata(text: string) {
|
||||
const regex = new RegExp(text);
|
||||
cy.get('.modal-body')
|
||||
cy.get('.ant-modal-body')
|
||||
.find('#json_metadata')
|
||||
.should('be.visible')
|
||||
.then(() => {
|
||||
@@ -50,12 +50,15 @@ function assertMetadata(text: string) {
|
||||
}
|
||||
|
||||
function typeMetadata(text: string) {
|
||||
cy.get('.modal-body').find('#json_metadata').should('be.visible').type(text);
|
||||
cy.get('.ant-modal-body')
|
||||
.find('#json_metadata')
|
||||
.should('be.visible')
|
||||
.type(text);
|
||||
}
|
||||
|
||||
function openAdvancedProperties() {
|
||||
return cy
|
||||
.get('.modal-body')
|
||||
.get('.ant-modal-body')
|
||||
.contains('Advanced')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
@@ -96,11 +99,11 @@ describe('Dashboard edit action', () => {
|
||||
|
||||
// save edit changes
|
||||
cy.get('.ant-modal-footer')
|
||||
.contains('Save')
|
||||
.contains('Apply')
|
||||
.click()
|
||||
.then(() => {
|
||||
// assert that modal edit window has closed
|
||||
cy.get('.ant-modal-body').should('not.exist');
|
||||
cy.get('.ant-modal-body').should('not.be.visible');
|
||||
|
||||
// assert title has been updated
|
||||
cy.get('.editable-title input').should('have.value', dashboardTitle);
|
||||
@@ -146,7 +149,7 @@ describe('Dashboard edit action', () => {
|
||||
.click()
|
||||
.then(() => {
|
||||
// assert that modal edit window has closed
|
||||
cy.get('.modal-body').should('not.exist');
|
||||
cy.get('.ant-modal-body').should('not.exist');
|
||||
|
||||
// assert color has been updated
|
||||
openDashboardEditProperties();
|
||||
@@ -177,7 +180,7 @@ describe('Dashboard edit action', () => {
|
||||
.click()
|
||||
.then(() => {
|
||||
// assert that modal edit window has closed
|
||||
cy.get('.modal-body')
|
||||
cy.get('.ant-modal-body')
|
||||
.contains('A valid color scheme is required')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
@@ -67,32 +67,30 @@ describe('Dashboard save action', () => {
|
||||
// should load chart
|
||||
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
|
||||
|
||||
// remove box_plot chart from dashboard
|
||||
// remove treemap chart from dashboard
|
||||
cy.get('[aria-label="edit-alt"]').click({ timeout: 5000 });
|
||||
cy.get('[data-test="dashboard-delete-component-button"]')
|
||||
.last()
|
||||
.trigger('moustenter')
|
||||
.trigger('mouseenter')
|
||||
.click();
|
||||
|
||||
cy.get('[data-test="grid-container"]')
|
||||
.find('.box_plot')
|
||||
.should('not.exist');
|
||||
cy.get('[data-test="grid-container"]').find('.treemap').should('not.exist');
|
||||
|
||||
cy.intercept('POST', '/superset/save_dash/**/').as('saveRequest');
|
||||
cy.intercept('PUT', '/api/v1/dashboard/**').as('putDashboardRequest');
|
||||
cy.get('[data-test="dashboard-header"]')
|
||||
.find('[data-test="header-save-button"]')
|
||||
.contains('Save')
|
||||
.click();
|
||||
|
||||
// go back to view mode
|
||||
cy.wait('@saveRequest');
|
||||
cy.wait('@putDashboardRequest');
|
||||
cy.get('[data-test="dashboard-header"]')
|
||||
.find('[aria-label="edit-alt"]')
|
||||
.click();
|
||||
|
||||
// deleted boxplot should still not exist
|
||||
// deleted treemap should still not exist
|
||||
cy.get('[data-test="grid-container"]')
|
||||
.find('.box_plot', { timeout: 20000 })
|
||||
.find('.treemap', { timeout: 20000 })
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('Visualization > Table', () => {
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P0.25Y',
|
||||
time_grain_sqla: 'P3M',
|
||||
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
|
||||
});
|
||||
// when format with smart_date, time column use format by granularity
|
||||
@@ -77,7 +77,7 @@ describe('Visualization > Table', () => {
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P0.25Y',
|
||||
time_grain_sqla: 'P3M',
|
||||
table_timestamp_format: '%Y-%m-%d %H:%M',
|
||||
metrics: [NUM_METRIC, MAX_DS, MAX_STATE],
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describe('Visualization > Table', () => {
|
||||
...VIZ_DEFAULTS,
|
||||
include_time: true,
|
||||
granularity_sqla: 'ds',
|
||||
time_grain_sqla: 'P0.25Y',
|
||||
time_grain_sqla: 'P3M',
|
||||
metrics: [NUM_METRIC, MAX_DS],
|
||||
groupby: ['name'],
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,8 @@ fetchMock.get('glob:*/api/v1/dashboard/*', {
|
||||
},
|
||||
});
|
||||
|
||||
describe('PropertiesModal', () => {
|
||||
// all these tests need to be moved to dashboard/components/PropertiesModal/PropertiesModal.test.tsx
|
||||
describe.skip('PropertiesModal', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
@@ -94,10 +95,12 @@ describe('PropertiesModal', () => {
|
||||
describe('without metadata', () => {
|
||||
const wrapper = setup({ colorScheme: 'SUPERSET_DEFAULT' });
|
||||
const modalInstance = wrapper.find('PropertiesModal').instance();
|
||||
it('does not update the color scheme in the metadata', () => {
|
||||
it('updates the color scheme in the metadata', () => {
|
||||
const spy = jest.spyOn(modalInstance, 'onMetadataChange');
|
||||
modalInstance.onColorSchemeChange('SUPERSET_DEFAULT');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'{"something": "foo", "color_scheme": "SUPERSET_DEFAULT", "label_colors": {}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('with metadata', () => {
|
||||
@@ -125,10 +128,12 @@ describe('PropertiesModal', () => {
|
||||
json_metadata: '{"timed_refresh_immune_slices": []}',
|
||||
},
|
||||
});
|
||||
it('will not update the metadata', () => {
|
||||
it('will update the metadata', () => {
|
||||
const spy = jest.spyOn(modalInstance, 'onMetadataChange');
|
||||
modalInstance.onColorSchemeChange('SUPERSET_DEFAULT');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'{"something": "foo", "color_scheme": "SUPERSET_DEFAULT", "label_colors": {}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('Chart', () => {
|
||||
expect(stubbedExportCSV.lastCall.args[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
formData: expect.anything(),
|
||||
resultType: 'results',
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -77,6 +77,7 @@ const Fade = styled.div`
|
||||
const TableElement = ({ table, actions, ...props }: TableElementProps) => {
|
||||
const [sortColumns, setSortColumns] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const tableNameRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const setHover = (hovered: boolean) => {
|
||||
debounce(() => setHovered(hovered), 100)();
|
||||
@@ -213,39 +214,50 @@ const TableElement = ({ table, actions, ...props }: TableElementProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div
|
||||
className="clearfix header-container"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<Tooltip
|
||||
id="copy-to-clipboard-tooltip"
|
||||
placement="topLeft"
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={table.name}
|
||||
trigger={['hover']}
|
||||
>
|
||||
<StyledSpan data-test="collapse" className="table-name">
|
||||
<strong>{table.name}</strong>
|
||||
</StyledSpan>
|
||||
</Tooltip>
|
||||
const renderHeader = () => {
|
||||
const element: HTMLInputElement | null = tableNameRef.current;
|
||||
let trigger: string[] = [];
|
||||
if (element && element.offsetWidth < element.scrollWidth) {
|
||||
trigger = ['hover'];
|
||||
}
|
||||
|
||||
<div className="pull-right header-right-side">
|
||||
{table.isMetadataLoading || table.isExtraMetadataLoading ? (
|
||||
<Loading position="inline" />
|
||||
) : (
|
||||
<Fade
|
||||
data-test="fade"
|
||||
hovered={hovered}
|
||||
onClick={e => e.stopPropagation()}
|
||||
return (
|
||||
<div
|
||||
className="clearfix header-container"
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<Tooltip
|
||||
id="copy-to-clipboard-tooltip"
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={table.name}
|
||||
trigger={trigger}
|
||||
>
|
||||
<StyledSpan
|
||||
data-test="collapse"
|
||||
ref={tableNameRef}
|
||||
className="table-name"
|
||||
>
|
||||
{renderControls()}
|
||||
</Fade>
|
||||
)}
|
||||
<strong>{table.name}</strong>
|
||||
</StyledSpan>
|
||||
</Tooltip>
|
||||
|
||||
<div className="pull-right header-right-side">
|
||||
{table.isMetadataLoading || table.isExtraMetadataLoading ? (
|
||||
<Loading position="inline" />
|
||||
) : (
|
||||
<Fade
|
||||
data-test="fade"
|
||||
hovered={hovered}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{renderControls()}
|
||||
</Fade>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
let cols;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -111,4 +111,6 @@ export enum FilterOperator {
|
||||
between = 'between',
|
||||
dashboardIsFav = 'dashboard_is_favorite',
|
||||
chartIsFav = 'chart_is_favorite',
|
||||
chartIsCertified = 'chart_is_certified',
|
||||
dashboardIsCertified = 'dashboard_is_certified',
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { styled, useTheme } from '@superset-ui/core';
|
||||
import { AntdCard, Skeleton, ThinSkeleton } from 'src/common/components';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
import ImageLoader, { BackgroundPosition } from './ImageLoader';
|
||||
import CertifiedIcon from '../CertifiedIcon';
|
||||
|
||||
const ActionsWrapper = styled.div`
|
||||
width: 64px;
|
||||
@@ -161,6 +162,8 @@ interface CardProps {
|
||||
rows?: number | string;
|
||||
avatar?: React.ReactElement | null;
|
||||
cover?: React.ReactNode | null;
|
||||
certifiedBy?: string;
|
||||
certificationDetails?: string;
|
||||
}
|
||||
|
||||
function ListViewCard({
|
||||
@@ -178,6 +181,8 @@ function ListViewCard({
|
||||
loading,
|
||||
imgPosition = 'top',
|
||||
cover,
|
||||
certifiedBy,
|
||||
certificationDetails,
|
||||
}: CardProps) {
|
||||
const Link = url && linkComponent ? linkComponent : AnchorLink;
|
||||
const theme = useTheme();
|
||||
@@ -249,7 +254,17 @@ function ListViewCard({
|
||||
<TitleContainer>
|
||||
<Tooltip title={title}>
|
||||
<TitleLink>
|
||||
<Link to={url!}>{title}</Link>
|
||||
<Link to={url!}>
|
||||
{certifiedBy && (
|
||||
<>
|
||||
<CertifiedIcon
|
||||
certifiedBy={certifiedBy}
|
||||
details={certificationDetails}
|
||||
/>{' '}
|
||||
</>
|
||||
)}
|
||||
{title}
|
||||
</Link>
|
||||
</TitleLink>
|
||||
</Tooltip>
|
||||
{titleRight && <TitleRight>{titleRight}</TitleRight>}
|
||||
|
||||
@@ -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 -10:00 (Hawaii Standard Time)',
|
||||
);
|
||||
expect(selection).toBeInTheDocument();
|
||||
userEvent.click(selection);
|
||||
expect(selection).toBeVisible();
|
||||
});
|
||||
it('renders a TimezoneSelector with the closest value if passed in', async () => {
|
||||
render(
|
||||
<TimezoneSelector
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
/* eslint camelcase: 0 */
|
||||
import { ActionCreators as UndoActionCreators } from 'redux-undo';
|
||||
import { t, SupersetClient } from '@superset-ui/core';
|
||||
import { ensureIsArray, t, SupersetClient } from '@superset-ui/core';
|
||||
import { addChart, removeChart, refreshChart } from 'src/chart/chartAction';
|
||||
import { chart as initChart } from 'src/chart/chartReducer';
|
||||
import { applyDefaultFormData } from 'src/explore/store';
|
||||
@@ -35,13 +35,18 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout';
|
||||
import { setChartConfiguration } from './dashboardInfo';
|
||||
import {
|
||||
setChartConfiguration,
|
||||
dashboardInfoChanged,
|
||||
SET_CHART_CONFIG_COMPLETE,
|
||||
} from './dashboardInfo';
|
||||
import { fetchDatasourceMetadata } from './datasources';
|
||||
import {
|
||||
addFilter,
|
||||
removeFilter,
|
||||
updateDirectPathToFilter,
|
||||
} from './dashboardFilters';
|
||||
import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters';
|
||||
|
||||
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
|
||||
export function setUnsavedChanges(hasUnsavedChanges) {
|
||||
@@ -171,8 +176,6 @@ export function saveDashboardRequestSuccess(lastModifiedTime) {
|
||||
}
|
||||
|
||||
export function saveDashboardRequest(data, id, saveType) {
|
||||
const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
|
||||
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST });
|
||||
|
||||
@@ -189,54 +192,184 @@ export function saveDashboardRequest(data, id, saveType) {
|
||||
const serializedFilters = serializeActiveFilterValues(getActiveFilters());
|
||||
// serialize filter scope for each filter field, grouped by filter id
|
||||
const serializedFilterScopes = serializeFilterScopes(dashboardFilters);
|
||||
const {
|
||||
certified_by,
|
||||
certification_details,
|
||||
css,
|
||||
dashboard_title,
|
||||
owners,
|
||||
roles,
|
||||
slug,
|
||||
} = data;
|
||||
|
||||
const hasId = item => item.id !== undefined;
|
||||
|
||||
// making sure the data is what the backend expects
|
||||
const cleanedData = {
|
||||
...data,
|
||||
certified_by: certified_by || '',
|
||||
certification_details:
|
||||
certified_by && certification_details ? certification_details : '',
|
||||
css: css || '',
|
||||
dashboard_title: dashboard_title || t('[ untitled dashboard ]'),
|
||||
owners: ensureIsArray(owners).map(o => (hasId(o) ? o.id : o)),
|
||||
roles: !isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)
|
||||
? undefined
|
||||
: ensureIsArray(roles).map(r => (hasId(r) ? r.id : r)),
|
||||
slug: slug || null,
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
color_namespace: data.metadata?.color_namespace || undefined,
|
||||
color_scheme: data.metadata?.color_scheme || '',
|
||||
expanded_slices: data.metadata?.expanded_slices || {},
|
||||
label_colors: data.metadata?.label_colors || {},
|
||||
refresh_frequency: data.metadata?.refresh_frequency || 0,
|
||||
timed_refresh_immune_slices:
|
||||
data.metadata?.timed_refresh_immune_slices || [],
|
||||
},
|
||||
};
|
||||
|
||||
const handleChartConfiguration = () => {
|
||||
const {
|
||||
dashboardInfo: {
|
||||
metadata: { chart_configuration = {} },
|
||||
},
|
||||
} = getState();
|
||||
const chartConfiguration = Object.values(chart_configuration).reduce(
|
||||
(prev, next) => {
|
||||
// If chart removed from dashboard - remove it from metadata
|
||||
if (
|
||||
Object.values(layout).find(
|
||||
layoutItem => layoutItem?.meta?.chartId === next.id,
|
||||
)
|
||||
) {
|
||||
return { ...prev, [next.id]: next };
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
{},
|
||||
);
|
||||
return chartConfiguration;
|
||||
};
|
||||
|
||||
const onCopySuccess = response => {
|
||||
const lastModifiedTime = response.json.last_modified_time;
|
||||
if (lastModifiedTime) {
|
||||
dispatch(saveDashboardRequestSuccess(lastModifiedTime));
|
||||
}
|
||||
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
|
||||
const chartConfiguration = handleChartConfiguration();
|
||||
dispatch(setChartConfiguration(chartConfiguration));
|
||||
}
|
||||
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
|
||||
return response;
|
||||
};
|
||||
|
||||
const onUpdateSuccess = response => {
|
||||
const updatedDashboard = response.json.result;
|
||||
const lastModifiedTime = response.json.last_modified_time;
|
||||
// synching with the backend transformations of the metadata
|
||||
if (updatedDashboard.json_metadata) {
|
||||
const metadata = JSON.parse(updatedDashboard.json_metadata);
|
||||
dispatch(
|
||||
dashboardInfoChanged({
|
||||
metadata,
|
||||
}),
|
||||
);
|
||||
if (metadata.chart_configuration) {
|
||||
dispatch({
|
||||
type: SET_CHART_CONFIG_COMPLETE,
|
||||
chartConfiguration: metadata.chart_configuration,
|
||||
});
|
||||
}
|
||||
if (metadata.native_filter_configuration) {
|
||||
dispatch({
|
||||
type: SET_FILTER_CONFIG_COMPLETE,
|
||||
filterConfig: metadata.native_filter_configuration,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (lastModifiedTime) {
|
||||
dispatch(saveDashboardRequestSuccess(lastModifiedTime));
|
||||
}
|
||||
// redirect to the new slug or id
|
||||
window.history.pushState(
|
||||
{ event: 'dashboard_properties_changed' },
|
||||
'',
|
||||
`/superset/dashboard/${slug || id}/`,
|
||||
);
|
||||
|
||||
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
|
||||
return response;
|
||||
};
|
||||
|
||||
const onError = async response => {
|
||||
const { error, message } = await getClientErrorObject(response);
|
||||
let errorText = t('Sorry, an unknown error occured');
|
||||
|
||||
if (error) {
|
||||
errorText = t(
|
||||
'Sorry, there was an error saving this dashboard: %s',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (typeof message === 'string' && message === 'Forbidden') {
|
||||
errorText = t('You do not have permission to edit this dashboard');
|
||||
}
|
||||
dispatch(addDangerToast(errorText));
|
||||
};
|
||||
|
||||
if (saveType === SAVE_TYPE_OVERWRITE) {
|
||||
let chartConfiguration = {};
|
||||
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
|
||||
chartConfiguration = handleChartConfiguration();
|
||||
}
|
||||
const updatedDashboard = {
|
||||
certified_by: cleanedData.certified_by,
|
||||
certification_details: cleanedData.certification_details,
|
||||
css: cleanedData.css,
|
||||
dashboard_title: cleanedData.dashboard_title,
|
||||
slug: cleanedData.slug,
|
||||
owners: cleanedData.owners,
|
||||
roles: cleanedData.roles,
|
||||
json_metadata: safeStringify({
|
||||
...(cleanedData?.metadata || {}),
|
||||
default_filters: safeStringify(serializedFilters),
|
||||
filter_scopes: serializedFilterScopes,
|
||||
chart_configuration: chartConfiguration,
|
||||
}),
|
||||
};
|
||||
|
||||
return SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedDashboard),
|
||||
})
|
||||
.then(response => onUpdateSuccess(response))
|
||||
.catch(response => onError(response));
|
||||
}
|
||||
// changing the data as the endpoint requires
|
||||
const copyData = cleanedData;
|
||||
if (copyData.metadata) {
|
||||
delete copyData.metadata;
|
||||
}
|
||||
const finalCopyData = {
|
||||
...copyData,
|
||||
// the endpoint is expecting the metadata to be flat
|
||||
...(cleanedData?.metadata || {}),
|
||||
};
|
||||
return SupersetClient.post({
|
||||
endpoint: `/superset/${path}/${id}/`,
|
||||
endpoint: `/superset/copy_dash/${id}/`,
|
||||
postPayload: {
|
||||
data: {
|
||||
...data,
|
||||
...finalCopyData,
|
||||
default_filters: safeStringify(serializedFilters),
|
||||
filter_scopes: safeStringify(serializedFilterScopes),
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) {
|
||||
const {
|
||||
dashboardInfo: {
|
||||
metadata: { chart_configuration = {} },
|
||||
},
|
||||
} = getState();
|
||||
const chartConfiguration = Object.values(chart_configuration).reduce(
|
||||
(prev, next) => {
|
||||
// If chart removed from dashboard - remove it from metadata
|
||||
if (
|
||||
Object.values(layout).find(
|
||||
layoutItem => layoutItem?.meta?.chartId === next.id,
|
||||
)
|
||||
) {
|
||||
return { ...prev, [next.id]: next };
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
{},
|
||||
);
|
||||
dispatch(setChartConfiguration(chartConfiguration));
|
||||
}
|
||||
dispatch(saveDashboardRequestSuccess(response.json.last_modified_time));
|
||||
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
|
||||
return response;
|
||||
})
|
||||
.catch(response =>
|
||||
getClientErrorObject(response).then(({ error }) =>
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
`${t(
|
||||
'Sorry, there was an error saving this dashboard: ',
|
||||
)} ${error}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
.then(response => onCopySuccess(response))
|
||||
.catch(response => onError(response));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,8 +503,8 @@ export function setDirectPathToChild(path) {
|
||||
}
|
||||
|
||||
export const SET_ACTIVE_TABS = 'SET_ACTIVE_TABS';
|
||||
export function setActiveTabs(tabIds) {
|
||||
return { type: SET_ACTIVE_TABS, tabIds };
|
||||
export function setActiveTabs(tabId, prevTabId) {
|
||||
return { type: SET_ACTIVE_TABS, tabId, prevTabId };
|
||||
}
|
||||
|
||||
export const SET_FOCUSED_FILTER_FIELD = 'SET_FOCUSED_FILTER_FIELD';
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,25 @@ export const setInScopeStatusOfFilters = (
|
||||
type: SET_IN_SCOPE_STATUS_OF_FILTERS,
|
||||
filterConfig: filtersWithScopes,
|
||||
});
|
||||
// need to update native_filter_configuration in the dashboard metadata
|
||||
const { metadata } = getState().dashboardInfo;
|
||||
const filterConfig: FilterConfiguration =
|
||||
metadata.native_filter_configuration;
|
||||
const mergedFilterConfig = filterConfig.map(filter => {
|
||||
const filterWithScope = filtersWithScopes.find(
|
||||
scope => scope.id === filter.id,
|
||||
);
|
||||
if (!filterWithScope) {
|
||||
return filter;
|
||||
}
|
||||
return { ...filterWithScope, ...filter };
|
||||
});
|
||||
metadata.native_filter_configuration = mergedFilterConfig;
|
||||
dispatch(
|
||||
dashboardInfoChanged({
|
||||
metadata,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
type BootstrapData = {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -117,11 +117,12 @@ const REPORT_ENDPOINT = 'glob:*/api/v1/report*';
|
||||
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
|
||||
fetchMock.get(REPORT_ENDPOINT, {});
|
||||
|
||||
function setup(props: HeaderProps) {
|
||||
return (
|
||||
function setup(props: HeaderProps, initialState = {}) {
|
||||
return render(
|
||||
<div className="dashboard">
|
||||
<Header {...props} />
|
||||
</div>
|
||||
</div>,
|
||||
{ useRedux: true, initialState },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,23 +134,23 @@ async function openActionsDropdown() {
|
||||
|
||||
test('should render', () => {
|
||||
const mockedProps = createProps();
|
||||
const { container } = render(setup(mockedProps));
|
||||
const { container } = setup(mockedProps);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the title', () => {
|
||||
const mockedProps = createProps();
|
||||
render(setup(mockedProps));
|
||||
setup(mockedProps);
|
||||
expect(screen.getByText('Dashboard Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the editable title', () => {
|
||||
render(setup(editableProps));
|
||||
setup(editableProps);
|
||||
expect(screen.getByDisplayValue('Dashboard Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should edit the title', () => {
|
||||
render(setup(editableProps));
|
||||
setup(editableProps);
|
||||
const editableTitle = screen.getByDisplayValue('Dashboard Title');
|
||||
expect(editableProps.onChange).not.toHaveBeenCalled();
|
||||
userEvent.click(editableTitle);
|
||||
@@ -162,12 +163,12 @@ test('should edit the title', () => {
|
||||
|
||||
test('should render the "Draft" status', () => {
|
||||
const mockedProps = createProps();
|
||||
render(setup(mockedProps));
|
||||
setup(mockedProps);
|
||||
expect(screen.getByText('Draft')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should publish', () => {
|
||||
render(setup(editableProps));
|
||||
setup(editableProps);
|
||||
const draft = screen.getByText('Draft');
|
||||
expect(editableProps.savePublished).not.toHaveBeenCalled();
|
||||
userEvent.click(draft);
|
||||
@@ -175,12 +176,12 @@ test('should publish', () => {
|
||||
});
|
||||
|
||||
test('should render the "Undo" action as disabled', () => {
|
||||
render(setup(editableProps));
|
||||
setup(editableProps);
|
||||
expect(screen.getByTitle('Undo').parentElement).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should undo', () => {
|
||||
render(setup(undoProps));
|
||||
setup(undoProps);
|
||||
const undo = screen.getByTitle('Undo');
|
||||
expect(undoProps.onUndo).not.toHaveBeenCalled();
|
||||
userEvent.click(undo);
|
||||
@@ -189,19 +190,19 @@ test('should undo', () => {
|
||||
|
||||
test('should undo with key listener', () => {
|
||||
undoProps.onUndo.mockReset();
|
||||
render(setup(undoProps));
|
||||
setup(undoProps);
|
||||
expect(undoProps.onUndo).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(document.body, { key: 'z', code: 'KeyZ', ctrlKey: true });
|
||||
expect(undoProps.onUndo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should render the "Redo" action as disabled', () => {
|
||||
render(setup(editableProps));
|
||||
setup(editableProps);
|
||||
expect(screen.getByTitle('Redo').parentElement).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should redo', () => {
|
||||
render(setup(redoProps));
|
||||
setup(redoProps);
|
||||
const redo = screen.getByTitle('Redo');
|
||||
expect(redoProps.onRedo).not.toHaveBeenCalled();
|
||||
userEvent.click(redo);
|
||||
@@ -210,19 +211,19 @@ test('should redo', () => {
|
||||
|
||||
test('should redo with key listener', () => {
|
||||
redoProps.onRedo.mockReset();
|
||||
render(setup(redoProps));
|
||||
setup(redoProps);
|
||||
expect(redoProps.onRedo).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(document.body, { key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
expect(redoProps.onRedo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should render the "Discard changes" button', () => {
|
||||
render(setup(editableProps));
|
||||
setup(editableProps);
|
||||
expect(screen.getByText('Discard changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the "Save" button as disabled', () => {
|
||||
render(setup(editableProps));
|
||||
setup(editableProps);
|
||||
expect(screen.getByText('Save').parentElement).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -231,7 +232,7 @@ test('should save', () => {
|
||||
...editableProps,
|
||||
hasUnsavedChanges: true,
|
||||
};
|
||||
render(setup(unsavedProps));
|
||||
setup(unsavedProps);
|
||||
const save = screen.getByText('Save');
|
||||
expect(unsavedProps.onSave).not.toHaveBeenCalled();
|
||||
userEvent.click(save);
|
||||
@@ -244,13 +245,13 @@ test('should NOT render the "Draft" status', () => {
|
||||
...mockedProps,
|
||||
isPublished: true,
|
||||
};
|
||||
render(setup(publishedProps));
|
||||
setup(publishedProps);
|
||||
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the unselected fave icon', () => {
|
||||
const mockedProps = createProps();
|
||||
render(setup(mockedProps));
|
||||
setup(mockedProps);
|
||||
expect(mockedProps.fetchFaveStar).toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'favorite-unselected' }),
|
||||
@@ -263,7 +264,7 @@ test('should render the selected fave icon', () => {
|
||||
...mockedProps,
|
||||
isStarred: true,
|
||||
};
|
||||
render(setup(favedProps));
|
||||
setup(favedProps);
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'favorite-selected' }),
|
||||
).toBeInTheDocument();
|
||||
@@ -275,7 +276,7 @@ test('should NOT render the fave icon on anonymous user', () => {
|
||||
...mockedProps,
|
||||
user: undefined,
|
||||
};
|
||||
render(setup(anonymousUserProps));
|
||||
setup(anonymousUserProps);
|
||||
expect(() =>
|
||||
screen.getByRole('img', { name: 'favorite-unselected' }),
|
||||
).toThrowError('Unable to find');
|
||||
@@ -286,7 +287,7 @@ test('should NOT render the fave icon on anonymous user', () => {
|
||||
|
||||
test('should fave', async () => {
|
||||
const mockedProps = createProps();
|
||||
render(setup(mockedProps));
|
||||
setup(mockedProps);
|
||||
const fave = screen.getByRole('img', { name: 'favorite-unselected' });
|
||||
expect(mockedProps.saveFaveStar).not.toHaveBeenCalled();
|
||||
userEvent.click(fave);
|
||||
@@ -302,7 +303,7 @@ test('should toggle the edit mode', () => {
|
||||
dash_edit_perm: true,
|
||||
},
|
||||
};
|
||||
render(setup(canEditProps));
|
||||
setup(canEditProps);
|
||||
const editDashboard = screen.getByTitle('Edit dashboard');
|
||||
expect(screen.queryByTitle('Edit dashboard')).toBeInTheDocument();
|
||||
userEvent.click(editDashboard);
|
||||
@@ -311,13 +312,13 @@ test('should toggle the edit mode', () => {
|
||||
|
||||
test('should render the dropdown icon', () => {
|
||||
const mockedProps = createProps();
|
||||
render(setup(mockedProps));
|
||||
setup(mockedProps);
|
||||
expect(screen.getByRole('img', { name: 'more-horiz' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should refresh the charts', async () => {
|
||||
const mockedProps = createProps();
|
||||
render(setup(mockedProps));
|
||||
setup(mockedProps);
|
||||
await openActionsDropdown();
|
||||
userEvent.click(screen.getByText('Refresh dashboard'));
|
||||
expect(mockedProps.onRefresh).toHaveBeenCalledTimes(1);
|
||||
@@ -341,7 +342,7 @@ describe('Email Report Modal', () => {
|
||||
it('creates a new email report', async () => {
|
||||
// ---------- Render/value setup ----------
|
||||
const mockedProps = createProps();
|
||||
render(setup(mockedProps), { useRedux: true });
|
||||
setup(mockedProps);
|
||||
|
||||
const reportValues = {
|
||||
id: 1,
|
||||
@@ -423,10 +424,7 @@ describe('Email Report Modal', () => {
|
||||
};
|
||||
|
||||
// getMockStore({ reports: reportValues });
|
||||
render(setup(mockedProps), {
|
||||
useRedux: true,
|
||||
initialState: mockState,
|
||||
});
|
||||
setup(mockedProps, mockState);
|
||||
// TODO (lyndsiWilliams): currently fetchMock detects this PUT
|
||||
// address as 'glob:*/api/v1/report/undefined', is not detected
|
||||
// on fetchMock.calls()
|
||||
@@ -465,4 +463,31 @@ describe('Email Report Modal', () => {
|
||||
// BLOCKER: I cannot get report to populate, as its data is handled through redux
|
||||
expect.anything();
|
||||
});
|
||||
|
||||
it('Should render report header', async () => {
|
||||
const mockedProps = createProps();
|
||||
setup(mockedProps);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Schedule email report' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render report header even with menu access for anonymous user', async () => {
|
||||
const mockedProps = createProps();
|
||||
const anonymousUserProps = {
|
||||
...mockedProps,
|
||||
user: {
|
||||
roles: {
|
||||
Public: [['menu_access', 'Manage']],
|
||||
},
|
||||
permissions: {
|
||||
datasource_access: ['[examples].[birth_names](id:2)'],
|
||||
},
|
||||
},
|
||||
};
|
||||
setup(anonymousUserProps);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Schedule email report' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { styled, CategoricalColorNamespace, t } from '@superset-ui/core';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import ButtonGroup from 'src/components/ButtonGroup';
|
||||
import CertifiedIcon from 'src/components/CertifiedIcon';
|
||||
|
||||
import {
|
||||
LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD,
|
||||
@@ -323,45 +324,38 @@ class Header extends React.PureComponent {
|
||||
const {
|
||||
dashboardTitle,
|
||||
layout: positions,
|
||||
expandedSlices,
|
||||
customCss,
|
||||
colorNamespace,
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
customCss,
|
||||
dashboardInfo,
|
||||
refreshFrequency: currentRefreshFrequency,
|
||||
shouldPersistRefreshFrequency,
|
||||
lastModifiedTime,
|
||||
slug,
|
||||
} = this.props;
|
||||
|
||||
const scale = CategoricalColorNamespace.getScale(
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
);
|
||||
|
||||
// use the colorScheme for default labels
|
||||
let labelColors = colorScheme ? scale.getColorMap() : {};
|
||||
// but allow metadata to overwrite if it exists
|
||||
// eslint-disable-next-line camelcase
|
||||
const metadataLabelColors = dashboardInfo.metadata?.label_colors;
|
||||
if (metadataLabelColors) {
|
||||
labelColors = { ...labelColors, ...metadataLabelColors };
|
||||
}
|
||||
|
||||
// check refresh frequency is for current session or persist
|
||||
const refreshFrequency = shouldPersistRefreshFrequency
|
||||
? currentRefreshFrequency
|
||||
: dashboardInfo.metadata?.refresh_frequency; // eslint-disable-line camelcase
|
||||
: dashboardInfo.metadata?.refresh_frequency;
|
||||
|
||||
const data = {
|
||||
positions,
|
||||
expanded_slices: expandedSlices,
|
||||
certified_by: dashboardInfo.certified_by,
|
||||
certification_details: dashboardInfo.certification_details,
|
||||
css: customCss,
|
||||
color_namespace: colorNamespace,
|
||||
color_scheme: colorScheme,
|
||||
label_colors: labelColors,
|
||||
dashboard_title: dashboardTitle,
|
||||
refresh_frequency: refreshFrequency,
|
||||
last_modified_time: lastModifiedTime,
|
||||
owners: dashboardInfo.owners,
|
||||
roles: dashboardInfo.roles,
|
||||
slug,
|
||||
metadata: {
|
||||
...dashboardInfo?.metadata,
|
||||
color_namespace:
|
||||
dashboardInfo?.metadata?.color_namespace || colorNamespace,
|
||||
color_scheme: dashboardInfo?.metadata?.color_scheme || colorScheme,
|
||||
positions,
|
||||
refresh_frequency: refreshFrequency,
|
||||
},
|
||||
};
|
||||
|
||||
// make sure positions data less than DB storage limitation:
|
||||
@@ -479,6 +473,20 @@ class Header extends React.PureComponent {
|
||||
dashboardInfo.common.conf
|
||||
.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_WARNING_MESSAGE;
|
||||
|
||||
const handleOnPropertiesChange = updates => {
|
||||
const { dashboardInfoChanged, dashboardTitleChanged } = this.props;
|
||||
dashboardInfoChanged({
|
||||
slug: updates.slug,
|
||||
metadata: JSON.parse(updates.jsonMetadata || '{}'),
|
||||
certified_by: updates.certifiedBy,
|
||||
certification_details: updates.certificationDetails,
|
||||
owners: updates.owners,
|
||||
roles: updates.roles,
|
||||
});
|
||||
setColorSchemeAndUnsavedChanges(updates.colorScheme);
|
||||
dashboardTitleChanged(updates.title);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDashboardHeader
|
||||
className="dashboard-header"
|
||||
@@ -486,6 +494,14 @@ class Header extends React.PureComponent {
|
||||
data-test-id={`${dashboardInfo.id}`}
|
||||
>
|
||||
<div className="dashboard-component-header header-large">
|
||||
{dashboardInfo.certified_by && (
|
||||
<>
|
||||
<CertifiedIcon
|
||||
certifiedBy={dashboardInfo.certified_by}
|
||||
details={dashboardInfo.certification_details}
|
||||
/>{' '}
|
||||
</>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={dashboardTitle}
|
||||
canEdit={userCanEdit && editMode}
|
||||
@@ -592,33 +608,16 @@ class Header extends React.PureComponent {
|
||||
)}
|
||||
{shouldShowReport && this.renderReportModal()}
|
||||
|
||||
{this.state.showingPropertiesModal && (
|
||||
<PropertiesModal
|
||||
dashboardId={dashboardInfo.id}
|
||||
show={this.state.showingPropertiesModal}
|
||||
onHide={this.hidePropertiesModal}
|
||||
colorScheme={this.props.colorScheme}
|
||||
onSubmit={updates => {
|
||||
const {
|
||||
dashboardInfoChanged,
|
||||
dashboardTitleChanged,
|
||||
} = this.props;
|
||||
dashboardInfoChanged({
|
||||
slug: updates.slug,
|
||||
metadata: JSON.parse(updates.jsonMetadata),
|
||||
});
|
||||
setColorSchemeAndUnsavedChanges(updates.colorScheme);
|
||||
dashboardTitleChanged(updates.title);
|
||||
if (updates.slug) {
|
||||
window.history.pushState(
|
||||
{ event: 'dashboard_properties_changed' },
|
||||
'',
|
||||
`/superset/dashboard/${updates.slug}/`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PropertiesModal
|
||||
dashboardId={dashboardInfo.id}
|
||||
dashboardInfo={dashboardInfo}
|
||||
dashboardTitle={dashboardTitle}
|
||||
show={this.state.showingPropertiesModal}
|
||||
onHide={this.hidePropertiesModal}
|
||||
colorScheme={this.props.colorScheme}
|
||||
onSubmit={handleOnPropertiesChange}
|
||||
onlyApply
|
||||
/>
|
||||
|
||||
{this.state.showingReportModal && (
|
||||
<ReportModal
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,546 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Row, Col, Input } from 'src/common/components';
|
||||
import { Form, FormItem } from 'src/components/Form';
|
||||
import jsonStringify from 'json-stringify-pretty-compact';
|
||||
import Button from 'src/components/Button';
|
||||
import { Select } from 'src/components';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
styled,
|
||||
t,
|
||||
SupersetClient,
|
||||
getCategoricalSchemeRegistry,
|
||||
CategoricalColorNamespace,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import Modal from 'src/components/Modal';
|
||||
import { JsonEditor } from 'src/components/AsyncAceEditor';
|
||||
|
||||
import ColorSchemeControlWrapper from 'src/dashboard/components/ColorSchemeControlWrapper';
|
||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
|
||||
const StyledJsonEditor = styled(JsonEditor)`
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
border: 1px solid ${({ theme }) => theme.colors.secondary.light2};
|
||||
`;
|
||||
|
||||
const propTypes = {
|
||||
dashboardId: PropTypes.number.isRequired,
|
||||
show: PropTypes.bool,
|
||||
onHide: PropTypes.func,
|
||||
colorScheme: PropTypes.string,
|
||||
setColorSchemeAndUnsavedChanges: PropTypes.func,
|
||||
onSubmit: PropTypes.func,
|
||||
addSuccessToast: PropTypes.func.isRequired,
|
||||
onlyApply: PropTypes.bool,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onHide: () => {},
|
||||
setColorSchemeAndUnsavedChanges: () => {},
|
||||
onSubmit: () => {},
|
||||
show: false,
|
||||
colorScheme: undefined,
|
||||
onlyApply: false,
|
||||
};
|
||||
|
||||
const handleErrorResponse = async response => {
|
||||
const { error, statusText, message } = await getClientErrorObject(response);
|
||||
let errorText = error || statusText || t('An error has occurred');
|
||||
|
||||
if (typeof message === 'object' && message.json_metadata) {
|
||||
errorText = message.json_metadata;
|
||||
} else if (typeof message === 'string') {
|
||||
errorText = message;
|
||||
|
||||
if (message === 'Forbidden') {
|
||||
errorText = t('You do not have permission to edit this dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
Modal.error({
|
||||
title: 'Error',
|
||||
content: errorText,
|
||||
okButtonProps: { danger: true, className: 'btn-danger' },
|
||||
});
|
||||
};
|
||||
|
||||
const loadAccessOptions = accessType => (input = '') => {
|
||||
const query = rison.encode({ filter: input });
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/related/${accessType}?q=${query}`,
|
||||
}).then(
|
||||
response => ({
|
||||
data: response.json.result.map(item => ({
|
||||
value: item.value,
|
||||
label: item.text,
|
||||
})),
|
||||
totalCount: response.json.count,
|
||||
}),
|
||||
badResponse => {
|
||||
handleErrorResponse(badResponse);
|
||||
return [];
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const loadOwners = loadAccessOptions('owners');
|
||||
const loadRoles = loadAccessOptions('roles');
|
||||
|
||||
class PropertiesModal extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
errors: [],
|
||||
values: {
|
||||
dashboard_title: '',
|
||||
slug: '',
|
||||
owners: [],
|
||||
roles: [],
|
||||
json_metadata: '',
|
||||
colorScheme: props.colorScheme,
|
||||
},
|
||||
isDashboardLoaded: false,
|
||||
isAdvancedOpen: false,
|
||||
};
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onMetadataChange = this.onMetadataChange.bind(this);
|
||||
this.onOwnersChange = this.onOwnersChange.bind(this);
|
||||
this.onRolesChange = this.onRolesChange.bind(this);
|
||||
this.submit = this.submit.bind(this);
|
||||
this.toggleAdvanced = this.toggleAdvanced.bind(this);
|
||||
this.onColorSchemeChange = this.onColorSchemeChange.bind(this);
|
||||
this.getRowsWithRoles = this.getRowsWithRoles.bind(this);
|
||||
this.getRowsWithoutRoles = this.getRowsWithoutRoles.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchDashboardDetails();
|
||||
JsonEditor.preload();
|
||||
}
|
||||
|
||||
onColorSchemeChange(value, { updateMetadata = true } = {}) {
|
||||
// check that color_scheme is valid
|
||||
const colorChoices = getCategoricalSchemeRegistry().keys();
|
||||
const { json_metadata: jsonMetadata } = this.state.values;
|
||||
const jsonMetadataObj = jsonMetadata?.length
|
||||
? JSON.parse(jsonMetadata)
|
||||
: {};
|
||||
if (!colorChoices.includes(value)) {
|
||||
Modal.error({
|
||||
title: 'Error',
|
||||
content: t('A valid color scheme is required'),
|
||||
okButtonProps: { danger: true, className: 'btn-danger' },
|
||||
});
|
||||
throw new Error('A valid color scheme is required');
|
||||
}
|
||||
|
||||
// update metadata to match selection
|
||||
if (
|
||||
updateMetadata &&
|
||||
Object.keys(jsonMetadataObj).includes('color_scheme')
|
||||
) {
|
||||
jsonMetadataObj.color_scheme = value;
|
||||
jsonMetadataObj.label_colors = Object.keys(
|
||||
jsonMetadataObj.label_colors ?? {},
|
||||
).reduce(
|
||||
(prev, next) => ({
|
||||
...prev,
|
||||
[next]: CategoricalColorNamespace.getScale(value)(next),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
this.onMetadataChange(jsonStringify(jsonMetadataObj));
|
||||
}
|
||||
|
||||
this.updateFormState('colorScheme', value);
|
||||
}
|
||||
|
||||
onOwnersChange(value) {
|
||||
this.updateFormState('owners', value);
|
||||
}
|
||||
|
||||
onRolesChange(value) {
|
||||
this.updateFormState('roles', value);
|
||||
}
|
||||
|
||||
onMetadataChange(metadata) {
|
||||
this.updateFormState('json_metadata', metadata);
|
||||
}
|
||||
|
||||
onChange(e) {
|
||||
const { name, value } = e.target;
|
||||
this.updateFormState(name, value);
|
||||
}
|
||||
|
||||
fetchDashboardDetails() {
|
||||
// We fetch the dashboard details because not all code
|
||||
// that renders this component have all the values we need.
|
||||
// At some point when we have a more consistent frontend
|
||||
// datamodel, the dashboard could probably just be passed as a prop.
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${this.props.dashboardId}`,
|
||||
}).then(response => {
|
||||
const dashboard = response.json.result;
|
||||
const jsonMetadataObj = dashboard.json_metadata?.length
|
||||
? JSON.parse(dashboard.json_metadata)
|
||||
: {};
|
||||
|
||||
this.setState(state => ({
|
||||
isDashboardLoaded: true,
|
||||
values: {
|
||||
...state.values,
|
||||
dashboard_title: dashboard.dashboard_title || '',
|
||||
slug: dashboard.slug || '',
|
||||
// format json with 2-space indentation
|
||||
json_metadata: dashboard.json_metadata
|
||||
? jsonStringify(jsonMetadataObj)
|
||||
: '',
|
||||
colorScheme: jsonMetadataObj.color_scheme,
|
||||
},
|
||||
}));
|
||||
const initialSelectedOwners = dashboard.owners.map(owner => ({
|
||||
value: owner.id,
|
||||
label: `${owner.first_name} ${owner.last_name}`,
|
||||
}));
|
||||
const initialSelectedRoles = dashboard.roles.map(role => ({
|
||||
value: role.id,
|
||||
label: `${role.name}`,
|
||||
}));
|
||||
this.onOwnersChange(initialSelectedOwners);
|
||||
this.onRolesChange(initialSelectedRoles);
|
||||
}, handleErrorResponse);
|
||||
}
|
||||
|
||||
updateFormState(name, value) {
|
||||
this.setState(state => ({
|
||||
values: {
|
||||
...state.values,
|
||||
[name]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
toggleAdvanced() {
|
||||
this.setState(state => ({
|
||||
isAdvancedOpen: !state.isAdvancedOpen,
|
||||
}));
|
||||
}
|
||||
|
||||
submit(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const {
|
||||
values: {
|
||||
json_metadata: jsonMetadata,
|
||||
slug,
|
||||
dashboard_title: dashboardTitle,
|
||||
colorScheme,
|
||||
owners: ownersValue,
|
||||
roles: rolesValue,
|
||||
},
|
||||
} = this.state;
|
||||
const { onlyApply } = this.props;
|
||||
const owners = ownersValue?.map(o => o.value) ?? [];
|
||||
const roles = rolesValue?.map(o => o.value) ?? [];
|
||||
let metadataColorScheme;
|
||||
|
||||
// update color scheme to match metadata
|
||||
if (jsonMetadata?.length) {
|
||||
const { color_scheme: metadataColorScheme } = JSON.parse(jsonMetadata);
|
||||
if (metadataColorScheme) {
|
||||
this.onColorSchemeChange(metadataColorScheme, {
|
||||
updateMetadata: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const moreProps = {};
|
||||
const morePutProps = {};
|
||||
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
|
||||
moreProps.rolesIds = roles;
|
||||
morePutProps.roles = roles;
|
||||
}
|
||||
if (onlyApply) {
|
||||
this.props.onSubmit({
|
||||
id: this.props.dashboardId,
|
||||
title: dashboardTitle,
|
||||
slug,
|
||||
jsonMetadata,
|
||||
ownerIds: owners,
|
||||
colorScheme: metadataColorScheme || colorScheme,
|
||||
...moreProps,
|
||||
});
|
||||
this.props.onHide();
|
||||
} else {
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${this.props.dashboardId}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dashboard_title: dashboardTitle,
|
||||
slug: slug || null,
|
||||
json_metadata: jsonMetadata || null,
|
||||
owners,
|
||||
...morePutProps,
|
||||
}),
|
||||
}).then(({ json: { result } }) => {
|
||||
const moreResultProps = {};
|
||||
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
|
||||
moreResultProps.rolesIds = result.roles;
|
||||
}
|
||||
this.props.addSuccessToast(t('The dashboard has been saved'));
|
||||
this.props.onSubmit({
|
||||
id: this.props.dashboardId,
|
||||
title: result.dashboard_title,
|
||||
slug: result.slug,
|
||||
jsonMetadata: result.json_metadata,
|
||||
ownerIds: result.owners,
|
||||
colorScheme: metadataColorScheme || colorScheme,
|
||||
...moreResultProps,
|
||||
});
|
||||
this.props.onHide();
|
||||
}, handleErrorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
getRowsWithoutRoles() {
|
||||
const { values, isDashboardLoaded } = this.state;
|
||||
return (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
|
||||
<FormItem label={t('Owners')}>
|
||||
<Select
|
||||
allowClear
|
||||
ariaLabel={t('Owners')}
|
||||
disabled={!isDashboardLoaded}
|
||||
name="owners"
|
||||
mode="multiple"
|
||||
value={values.owners}
|
||||
options={loadOwners}
|
||||
onChange={this.onOwnersChange}
|
||||
/>
|
||||
<p className="help-block">
|
||||
{t(
|
||||
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
|
||||
)}
|
||||
</p>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<h3 style={{ marginTop: '1em' }}>{t('Colors')}</h3>
|
||||
<ColorSchemeControlWrapper
|
||||
onChange={this.onColorSchemeChange}
|
||||
colorScheme={values.colorScheme}
|
||||
labelMargin={4}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
getRowsWithRoles() {
|
||||
const { values, isDashboardLoaded } = this.state;
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col xs={24} md={24}>
|
||||
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormItem label={t('Owners')}>
|
||||
<Select
|
||||
allowClear
|
||||
ariaLabel={t('Owners')}
|
||||
disabled={!isDashboardLoaded}
|
||||
name="owners"
|
||||
mode="multiple"
|
||||
value={values.owners}
|
||||
options={loadOwners}
|
||||
onChange={this.onOwnersChange}
|
||||
/>
|
||||
<p className="help-block">
|
||||
{t(
|
||||
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
|
||||
)}
|
||||
</p>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormItem label={t('Roles')}>
|
||||
<Select
|
||||
allowClear
|
||||
ariaLabel={t('Roles')}
|
||||
disabled={!isDashboardLoaded}
|
||||
name="roles"
|
||||
mode="multiple"
|
||||
value={values.roles}
|
||||
options={loadRoles}
|
||||
onChange={this.onRolesChange}
|
||||
/>
|
||||
<p className="help-block">
|
||||
{t(
|
||||
'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles defined then the dashboard is available to all roles.',
|
||||
)}
|
||||
</p>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} md={12}>
|
||||
<ColorSchemeControlWrapper
|
||||
onChange={this.onColorSchemeChange}
|
||||
colorScheme={values.colorScheme}
|
||||
labelMargin={4}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { values, isDashboardLoaded, isAdvancedOpen, errors } = this.state;
|
||||
const { onHide, onlyApply } = this.props;
|
||||
|
||||
const saveLabel = onlyApply ? t('Apply') : t('Save');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={this.props.show}
|
||||
onHide={this.props.onHide}
|
||||
title={t('Dashboard properties')}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
htmlType="button"
|
||||
buttonSize="small"
|
||||
onClick={onHide}
|
||||
data-test="properties-modal-cancel-button"
|
||||
cta
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={this.submit}
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
className="m-r-5"
|
||||
disabled={errors.length > 0}
|
||||
cta
|
||||
>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
responsive
|
||||
>
|
||||
<Form
|
||||
data-test="dashboard-edit-properties-form"
|
||||
onSubmit={this.submit}
|
||||
layout="vertical"
|
||||
>
|
||||
<Row>
|
||||
<Col xs={24} md={24}>
|
||||
<h3>{t('Basic information')}</h3>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormItem label={t('Title')}>
|
||||
<Input
|
||||
data-test="dashboard-title-input"
|
||||
name="dashboard_title"
|
||||
type="text"
|
||||
value={values.dashboard_title}
|
||||
onChange={this.onChange}
|
||||
disabled={!isDashboardLoaded}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormItem label={t('URL slug')}>
|
||||
<Input
|
||||
name="slug"
|
||||
type="text"
|
||||
value={values.slug || ''}
|
||||
onChange={this.onChange}
|
||||
disabled={!isDashboardLoaded}
|
||||
/>
|
||||
<p className="help-block">
|
||||
{t('A readable URL for your dashboard')}
|
||||
</p>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
{isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)
|
||||
? this.getRowsWithRoles()
|
||||
: this.getRowsWithoutRoles()}
|
||||
<Row>
|
||||
<Col xs={24} md={24}>
|
||||
<h3 style={{ marginTop: '1em' }}>
|
||||
<Button buttonStyle="link" onClick={this.toggleAdvanced}>
|
||||
<i
|
||||
className={`fa fa-angle-${
|
||||
isAdvancedOpen ? 'down' : 'right'
|
||||
}`}
|
||||
style={{ minWidth: '1em' }}
|
||||
/>
|
||||
{t('Advanced')}
|
||||
</Button>
|
||||
</h3>
|
||||
{isAdvancedOpen && (
|
||||
<FormItem label={t('JSON metadata')}>
|
||||
<StyledJsonEditor
|
||||
showLoadingForImport
|
||||
name="json_metadata"
|
||||
defaultValue={this.defaultMetadataValue}
|
||||
value={values.json_metadata}
|
||||
onChange={this.onMetadataChange}
|
||||
tabSize={2}
|
||||
width="100%"
|
||||
height="200px"
|
||||
wrapEnabled
|
||||
/>
|
||||
<p className="help-block">
|
||||
{t(
|
||||
'This JSON object is generated dynamically when clicking the save or overwrite button in the dashboard view. It is exposed here for reference and for power users who may want to alter specific parameters.',
|
||||
)}
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PropertiesModal.propTypes = propTypes;
|
||||
PropertiesModal.defaultProps = defaultProps;
|
||||
|
||||
export default withToasts(PropertiesModal);
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Form, Row, Col, Input } from 'src/common/components';
|
||||
import { FormItem } from 'src/components/Form';
|
||||
import jsonStringify from 'json-stringify-pretty-compact';
|
||||
import Button from 'src/components/Button';
|
||||
import { Select } from 'src/components';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
styled,
|
||||
t,
|
||||
SupersetClient,
|
||||
getCategoricalSchemeRegistry,
|
||||
ensureIsArray,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import Modal from 'src/components/Modal';
|
||||
import { JsonEditor } from 'src/components/AsyncAceEditor';
|
||||
|
||||
import ColorSchemeControlWrapper from 'src/dashboard/components/ColorSchemeControlWrapper';
|
||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
|
||||
const StyledFormItem = styled(FormItem)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const StyledJsonEditor = styled(JsonEditor)`
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
border: 1px solid ${({ theme }) => theme.colors.secondary.light2};
|
||||
`;
|
||||
|
||||
type PropertiesModalProps = {
|
||||
dashboardId: number;
|
||||
dashboardTitle?: string;
|
||||
dashboardInfo?: Record<string, any>;
|
||||
show?: boolean;
|
||||
onHide?: () => void;
|
||||
colorScheme?: string;
|
||||
setColorSchemeAndUnsavedChanges?: () => void;
|
||||
onSubmit?: (params: Record<string, any>) => void;
|
||||
addSuccessToast: (message: string) => void;
|
||||
onlyApply?: boolean;
|
||||
};
|
||||
|
||||
type Roles = { id: number; name: string }[];
|
||||
type Owners = {
|
||||
id: number;
|
||||
full_name?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}[];
|
||||
type DashboardInfo = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
certifiedBy: string;
|
||||
certificationDetails: string;
|
||||
};
|
||||
|
||||
const PropertiesModal = ({
|
||||
addSuccessToast,
|
||||
colorScheme: currentColorScheme,
|
||||
dashboardId,
|
||||
dashboardInfo: currentDashboardInfo,
|
||||
dashboardTitle,
|
||||
onHide = () => {},
|
||||
onlyApply = false,
|
||||
onSubmit = () => {},
|
||||
show = false,
|
||||
}: PropertiesModalProps) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const [colorScheme, setColorScheme] = useState(currentColorScheme);
|
||||
const [jsonMetadata, setJsonMetadata] = useState('');
|
||||
const [dashboardInfo, setDashboardInfo] = useState<DashboardInfo>();
|
||||
const [owners, setOwners] = useState<Owners>([]);
|
||||
const [roles, setRoles] = useState<Roles>([]);
|
||||
const saveLabel = onlyApply ? t('Apply') : t('Save');
|
||||
|
||||
const handleErrorResponse = async (response: Response) => {
|
||||
const { error, statusText, message } = await getClientErrorObject(response);
|
||||
let errorText = error || statusText || t('An error has occurred');
|
||||
if (typeof message === 'object' && 'json_metadata' in message) {
|
||||
errorText = (message as { json_metadata: string }).json_metadata;
|
||||
} else if (typeof message === 'string') {
|
||||
errorText = message;
|
||||
|
||||
if (message === 'Forbidden') {
|
||||
errorText = t('You do not have permission to edit this dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
Modal.error({
|
||||
title: 'Error',
|
||||
content: errorText,
|
||||
okButtonProps: { danger: true, className: 'btn-danger' },
|
||||
});
|
||||
};
|
||||
|
||||
const loadAccessOptions = useCallback(
|
||||
(accessType = 'owners', input = '', page: number, pageSize: number) => {
|
||||
const query = rison.encode({
|
||||
filter: input,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/related/${accessType}?q=${query}`,
|
||||
}).then(response => ({
|
||||
data: response.json.result.map(
|
||||
(item: { value: number; text: string }) => ({
|
||||
value: item.value,
|
||||
label: item.text,
|
||||
}),
|
||||
),
|
||||
totalCount: response.json.count,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDashboardData = useCallback(
|
||||
dashboardData => {
|
||||
const {
|
||||
id,
|
||||
dashboard_title,
|
||||
slug,
|
||||
certified_by,
|
||||
certification_details,
|
||||
owners,
|
||||
roles,
|
||||
metadata,
|
||||
} = dashboardData;
|
||||
const dashboardInfo = {
|
||||
id,
|
||||
title: dashboard_title,
|
||||
slug: slug || '',
|
||||
certifiedBy: certified_by || '',
|
||||
certificationDetails: certification_details || '',
|
||||
};
|
||||
|
||||
form.setFieldsValue(dashboardInfo);
|
||||
setDashboardInfo(dashboardInfo);
|
||||
setOwners(owners);
|
||||
setRoles(roles);
|
||||
setColorScheme(metadata.color_scheme);
|
||||
|
||||
// temporary fix to remove positions from dashboards' metadata
|
||||
if (metadata?.positions) {
|
||||
delete metadata.positions;
|
||||
}
|
||||
setJsonMetadata(metadata ? jsonStringify(metadata) : '');
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const fetchDashboardDetails = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
// We fetch the dashboard details because not all code
|
||||
// that renders this component have all the values we need.
|
||||
// At some point when we have a more consistent frontend
|
||||
// datamodel, the dashboard could probably just be passed as a prop.
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${dashboardId}`,
|
||||
}).then(response => {
|
||||
const dashboard = response.json.result;
|
||||
const jsonMetadataObj = dashboard.json_metadata?.length
|
||||
? JSON.parse(dashboard.json_metadata)
|
||||
: {};
|
||||
|
||||
handleDashboardData({
|
||||
...dashboard,
|
||||
metadata: jsonMetadataObj,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
}, handleErrorResponse);
|
||||
}, [dashboardId, handleDashboardData]);
|
||||
|
||||
const getJsonMetadata = () => {
|
||||
try {
|
||||
const jsonMetadataObj = jsonMetadata?.length
|
||||
? JSON.parse(jsonMetadata)
|
||||
: {};
|
||||
return jsonMetadataObj;
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnChangeOwners = (owners: { value: number; label: string }[]) => {
|
||||
const parsedOwners: Owners = ensureIsArray(owners).map(o => ({
|
||||
id: o.value,
|
||||
full_name: o.label,
|
||||
}));
|
||||
setOwners(parsedOwners);
|
||||
};
|
||||
|
||||
const handleOnChangeRoles = (roles: { value: number; label: string }[]) => {
|
||||
const parsedRoles: Roles = ensureIsArray(roles).map(r => ({
|
||||
id: r.value,
|
||||
name: r.label,
|
||||
}));
|
||||
setRoles(parsedRoles);
|
||||
};
|
||||
|
||||
const handleOwnersSelectValue = () => {
|
||||
const parsedOwners = (owners || []).map(
|
||||
(owner: {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
full_name?: string;
|
||||
}) => ({
|
||||
value: owner.id,
|
||||
label: owner.full_name || `${owner.first_name} ${owner.last_name}`,
|
||||
}),
|
||||
);
|
||||
return parsedOwners;
|
||||
};
|
||||
|
||||
const handleRolesSelectValue = () => {
|
||||
const parsedRoles = (roles || []).map(
|
||||
(role: { id: number; name: string }) => ({
|
||||
value: role.id,
|
||||
label: `${role.name}`,
|
||||
}),
|
||||
);
|
||||
return parsedRoles;
|
||||
};
|
||||
|
||||
const onColorSchemeChange = (
|
||||
colorScheme?: string,
|
||||
{ updateMetadata = true } = {},
|
||||
) => {
|
||||
// check that color_scheme is valid
|
||||
const colorChoices = getCategoricalSchemeRegistry().keys();
|
||||
const jsonMetadataObj = getJsonMetadata();
|
||||
|
||||
// only fire if the color_scheme is present and invalid
|
||||
if (colorScheme && !colorChoices.includes(colorScheme)) {
|
||||
Modal.error({
|
||||
title: 'Error',
|
||||
content: t('A valid color scheme is required'),
|
||||
okButtonProps: { danger: true, className: 'btn-danger' },
|
||||
});
|
||||
throw new Error('A valid color scheme is required');
|
||||
}
|
||||
|
||||
// update metadata to match selection
|
||||
if (updateMetadata) {
|
||||
jsonMetadataObj.color_scheme = colorScheme;
|
||||
jsonMetadataObj.label_colors = jsonMetadataObj.label_colors || {};
|
||||
|
||||
setJsonMetadata(jsonStringify(jsonMetadataObj));
|
||||
}
|
||||
setColorScheme(colorScheme);
|
||||
};
|
||||
|
||||
const onFinish = () => {
|
||||
const {
|
||||
title,
|
||||
slug,
|
||||
certifiedBy,
|
||||
certificationDetails,
|
||||
} = form.getFieldsValue();
|
||||
let currentColorScheme = colorScheme;
|
||||
let colorNamespace = '';
|
||||
|
||||
// color scheme in json metadata has precedence over selection
|
||||
if (jsonMetadata?.length) {
|
||||
const metadata = JSON.parse(jsonMetadata);
|
||||
currentColorScheme = metadata?.color_scheme || colorScheme;
|
||||
colorNamespace = metadata?.color_namespace || '';
|
||||
}
|
||||
|
||||
onColorSchemeChange(currentColorScheme, {
|
||||
updateMetadata: false,
|
||||
});
|
||||
|
||||
const moreOnSubmitProps: { roles?: Roles } = {};
|
||||
const morePutProps: { roles?: number[] } = {};
|
||||
if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) {
|
||||
moreOnSubmitProps.roles = roles;
|
||||
morePutProps.roles = (roles || []).map(r => r.id);
|
||||
}
|
||||
const onSubmitProps = {
|
||||
id: dashboardId,
|
||||
title,
|
||||
slug,
|
||||
jsonMetadata,
|
||||
owners,
|
||||
colorScheme: currentColorScheme,
|
||||
colorNamespace,
|
||||
certifiedBy,
|
||||
certificationDetails,
|
||||
...moreOnSubmitProps,
|
||||
};
|
||||
if (onlyApply) {
|
||||
onSubmit(onSubmitProps);
|
||||
onHide();
|
||||
} else {
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${dashboardId}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dashboard_title: title,
|
||||
slug: slug || null,
|
||||
json_metadata: jsonMetadata || null,
|
||||
owners: (owners || []).map(o => o.id),
|
||||
certified_by: certifiedBy || null,
|
||||
certification_details:
|
||||
certifiedBy && certificationDetails ? certificationDetails : null,
|
||||
...morePutProps,
|
||||
}),
|
||||
}).then(() => {
|
||||
addSuccessToast(t('The dashboard has been saved'));
|
||||
onSubmit(onSubmitProps);
|
||||
onHide();
|
||||
}, handleErrorResponse);
|
||||
}
|
||||
};
|
||||
|
||||
const getRowsWithoutRoles = () => {
|
||||
const jsonMetadataObj = getJsonMetadata();
|
||||
const hasCustomLabelColors = !!Object.keys(
|
||||
jsonMetadataObj?.label_colors || {},
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
|
||||
<StyledFormItem label={t('Owners')}>
|
||||
<Select
|
||||
allowClear
|
||||
ariaLabel={t('Owners')}
|
||||
disabled={isLoading}
|
||||
mode="multiple"
|
||||
onChange={handleOnChangeOwners}
|
||||
options={(input, page, pageSize) =>
|
||||
loadAccessOptions('owners', input, page, pageSize)
|
||||
}
|
||||
value={handleOwnersSelectValue()}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
<p className="help-block">
|
||||
{t(
|
||||
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
|
||||
)}
|
||||
</p>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<h3 style={{ marginTop: '1em' }}>{t('Colors')}</h3>
|
||||
<ColorSchemeControlWrapper
|
||||
hasCustomLabelColors={hasCustomLabelColors}
|
||||
onChange={onColorSchemeChange}
|
||||
colorScheme={colorScheme}
|
||||
labelMargin={4}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const getRowsWithRoles = () => {
|
||||
const jsonMetadataObj = getJsonMetadata();
|
||||
const hasCustomLabelColors = !!Object.keys(
|
||||
jsonMetadataObj?.label_colors || {},
|
||||
).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col xs={24} md={24}>
|
||||
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledFormItem label={t('Owners')}>
|
||||
<Select
|
||||
allowClear
|
||||
ariaLabel={t('Owners')}
|
||||
disabled={isLoading}
|
||||
mode="multiple"
|
||||
onChange={handleOnChangeOwners}
|
||||
options={(input, page, pageSize) =>
|
||||
loadAccessOptions('owners', input, page, pageSize)
|
||||
}
|
||||
value={handleOwnersSelectValue()}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
<p className="help-block">
|
||||
{t(
|
||||
'Owners is a list of users who can alter the dashboard. Searchable by name or username.',
|
||||
)}
|
||||
</p>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledFormItem label={t('Roles')}>
|
||||
<Select
|
||||
allowClear
|
||||
ariaLabel={t('Roles')}
|
||||
disabled={isLoading}
|
||||
mode="multiple"
|
||||
onChange={handleOnChangeRoles}
|
||||
options={(input, page, pageSize) =>
|
||||
loadAccessOptions('roles', input, page, pageSize)
|
||||
}
|
||||
value={handleRolesSelectValue()}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
<p className="help-block">
|
||||
{t(
|
||||
'Roles is a list which defines access to the dashboard. Granting a role access to a dashboard will bypass dataset level checks. If no roles are defined, then the dashboard is available to all roles.',
|
||||
)}
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} md={12}>
|
||||
<ColorSchemeControlWrapper
|
||||
hasCustomLabelColors={hasCustomLabelColors}
|
||||
onChange={onColorSchemeChange}
|
||||
colorScheme={colorScheme}
|
||||
labelMargin={4}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
if (!currentDashboardInfo) {
|
||||
fetchDashboardDetails();
|
||||
} else {
|
||||
handleDashboardData(currentDashboardInfo);
|
||||
}
|
||||
}
|
||||
|
||||
JsonEditor.preload();
|
||||
}, [currentDashboardInfo, fetchDashboardDetails, handleDashboardData, show]);
|
||||
|
||||
useEffect(() => {
|
||||
// the title can be changed inline in the dashboard, this catches it
|
||||
if (
|
||||
dashboardTitle &&
|
||||
dashboardInfo &&
|
||||
dashboardInfo.title !== dashboardTitle
|
||||
) {
|
||||
form.setFieldsValue({
|
||||
...dashboardInfo,
|
||||
title: dashboardTitle,
|
||||
});
|
||||
}
|
||||
}, [dashboardInfo, dashboardTitle, form]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
title={t('Dashboard properties')}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
htmlType="button"
|
||||
buttonSize="small"
|
||||
onClick={onHide}
|
||||
data-test="properties-modal-cancel-button"
|
||||
cta
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={form.submit}
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
className="m-r-5"
|
||||
cta
|
||||
>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
responsive
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={onFinish}
|
||||
data-test="dashboard-edit-properties-form"
|
||||
layout="vertical"
|
||||
initialValues={dashboardInfo}
|
||||
>
|
||||
<Row>
|
||||
<Col xs={24} md={24}>
|
||||
<h3>{t('Basic information')}</h3>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormItem label={t('Title')} name="title">
|
||||
<Input
|
||||
data-test="dashboard-title-input"
|
||||
type="text"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledFormItem label={t('URL slug')} name="slug">
|
||||
<Input type="text" disabled={isLoading} />
|
||||
</StyledFormItem>
|
||||
<p className="help-block">
|
||||
{t('A readable URL for your dashboard')}
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
{isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)
|
||||
? getRowsWithRoles()
|
||||
: getRowsWithoutRoles()}
|
||||
<Row>
|
||||
<Col xs={24} md={24}>
|
||||
<h3>{t('Certification')}</h3>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledFormItem label={t('Certified by')} name="certifiedBy">
|
||||
<Input type="text" disabled={isLoading} />
|
||||
</StyledFormItem>
|
||||
<p className="help-block">
|
||||
{t('Person or group that has certified this dashboard.')}
|
||||
</p>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledFormItem
|
||||
label={t('Certification details')}
|
||||
name="certificationDetails"
|
||||
>
|
||||
<Input type="text" disabled={isLoading} />
|
||||
</StyledFormItem>
|
||||
<p className="help-block">
|
||||
{t('Any additional detail to show in the certification tooltip.')}
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={24} md={24}>
|
||||
<h3 style={{ marginTop: '1em' }}>
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
onClick={() => setIsAdvancedOpen(!isAdvancedOpen)}
|
||||
>
|
||||
<i
|
||||
className={`fa fa-angle-${isAdvancedOpen ? 'down' : 'right'}`}
|
||||
style={{ minWidth: '1em' }}
|
||||
/>
|
||||
{t('Advanced')}
|
||||
</Button>
|
||||
</h3>
|
||||
{isAdvancedOpen && (
|
||||
<>
|
||||
<StyledFormItem label={t('JSON metadata')}>
|
||||
<StyledJsonEditor
|
||||
showLoadingForImport
|
||||
name="json_metadata"
|
||||
value={jsonMetadata}
|
||||
onChange={setJsonMetadata}
|
||||
tabSize={2}
|
||||
width="100%"
|
||||
height="200px"
|
||||
wrapEnabled
|
||||
/>
|
||||
</StyledFormItem>
|
||||
<p className="help-block">
|
||||
{t(
|
||||
'This JSON object is generated dynamically when clicking the save or overwrite button in the dashboard view. It is exposed here for reference and for power users who may want to alter specific parameters.',
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default withToasts(PropertiesModal);
|
||||
@@ -21,7 +21,7 @@ import React from 'react';
|
||||
import { Radio } from 'src/components/Radio';
|
||||
import { RadioChangeEvent, Input } from 'src/common/components';
|
||||
import Button from 'src/components/Button';
|
||||
import { t, CategoricalColorNamespace, JsonResponse } from '@superset-ui/core';
|
||||
import { t, JsonResponse } from '@superset-ui/core';
|
||||
|
||||
import ModalTrigger from 'src/components/ModalTrigger';
|
||||
import Checkbox from 'src/components/Checkbox';
|
||||
@@ -122,37 +122,32 @@ class SaveModal extends React.PureComponent<SaveModalProps, SaveModalState> {
|
||||
dashboardInfo,
|
||||
layout: positions,
|
||||
customCss,
|
||||
colorNamespace,
|
||||
colorScheme,
|
||||
expandedSlices,
|
||||
dashboardId,
|
||||
refreshFrequency: currentRefreshFrequency,
|
||||
shouldPersistRefreshFrequency,
|
||||
lastModifiedTime,
|
||||
} = this.props;
|
||||
|
||||
const scale = CategoricalColorNamespace.getScale(
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
);
|
||||
const labelColors = colorScheme ? scale.getColorMap() : {};
|
||||
// check refresh frequency is for current session or persist
|
||||
const refreshFrequency = shouldPersistRefreshFrequency
|
||||
? currentRefreshFrequency
|
||||
: dashboardInfo.metadata?.refresh_frequency; // eslint-disable camelcase
|
||||
|
||||
const data = {
|
||||
positions,
|
||||
certified_by: dashboardInfo.certified_by,
|
||||
certification_details: dashboardInfo.certification_details,
|
||||
css: customCss,
|
||||
color_namespace: colorNamespace,
|
||||
color_scheme: colorScheme,
|
||||
label_colors: labelColors,
|
||||
expanded_slices: expandedSlices,
|
||||
dashboard_title:
|
||||
saveType === SAVE_TYPE_NEWDASHBOARD ? newDashName : dashboardTitle,
|
||||
duplicate_slices: this.state.duplicateSlices,
|
||||
refresh_frequency: refreshFrequency,
|
||||
last_modified_time: lastModifiedTime,
|
||||
owners: dashboardInfo.owners,
|
||||
roles: dashboardInfo.roles,
|
||||
metadata: {
|
||||
...dashboardInfo?.metadata,
|
||||
positions,
|
||||
refresh_frequency: refreshFrequency,
|
||||
},
|
||||
};
|
||||
|
||||
if (saveType === SAVE_TYPE_NEWDASHBOARD && !newDashName) {
|
||||
|
||||
@@ -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, {
|
||||
@@ -241,7 +242,7 @@ export default class Chart extends React.Component {
|
||||
formData: isFullCSV
|
||||
? { ...this.props.formData, row_limit: this.props.maxRows }
|
||||
: this.props.formData,
|
||||
resultType: 'results',
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
}
|
||||
@@ -274,6 +275,7 @@ export default class Chart extends React.Component {
|
||||
editMode,
|
||||
filters,
|
||||
formData,
|
||||
labelColors,
|
||||
updateSliceName,
|
||||
sliceName,
|
||||
toggleExpandSlice,
|
||||
@@ -396,6 +398,7 @@ export default class Chart extends React.Component {
|
||||
dashboardId={dashboardId}
|
||||
initialValues={initialValues}
|
||||
formData={formData}
|
||||
labelColors={labelColors}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
queriesResponse={chart.queriesResponse}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function getLeafComponentIdFromPath(directPathToChild = []) {
|
||||
|
||||
while (currentPath.length) {
|
||||
const componentId = currentPath.pop();
|
||||
const componentType = componentId.split('-')[0];
|
||||
const componentType = componentId && componentId.split('-')[0];
|
||||
|
||||
if (!IN_COMPONENT_ELEMENT_TYPES.includes(componentType)) {
|
||||
return componentId;
|
||||
|
||||
@@ -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 {
|
||||
@@ -31,16 +36,17 @@ import {
|
||||
} from 'src/reports/actions/reports';
|
||||
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
|
||||
import HeaderReportActionsDropdown from 'src/components/ReportModal/HeaderReportActionsDropdown';
|
||||
import { chartPropShape } from '../../dashboard/util/propShapes';
|
||||
import { chartPropShape } from 'src/dashboard/util/propShapes';
|
||||
import EditableTitle from 'src/components/EditableTitle';
|
||||
import AlteredSliceTag from 'src/components/AlteredSliceTag';
|
||||
import FaveStar from 'src/components/FaveStar';
|
||||
import Timer from 'src/components/Timer';
|
||||
import CachedLabel from 'src/components/CachedLabel';
|
||||
import PropertiesModal from 'src/explore/components/PropertiesModal';
|
||||
import { sliceUpdated } from 'src/explore/actions/exploreActions';
|
||||
import CertifiedIcon from 'src/components/CertifiedIcon';
|
||||
import ExploreActionButtons from './ExploreActionButtons';
|
||||
import RowCountLabel from './RowCountLabel';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import AlteredSliceTag from '../../components/AlteredSliceTag';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
import Timer from '../../components/Timer';
|
||||
import CachedLabel from '../../components/CachedLabel';
|
||||
import PropertiesModal from './PropertiesModal';
|
||||
import { sliceUpdated } from '../actions/exploreActions';
|
||||
|
||||
const CHART_STATUS_MAP = {
|
||||
failed: 'danger',
|
||||
@@ -53,6 +59,7 @@ const propTypes = {
|
||||
addHistory: PropTypes.func,
|
||||
can_overwrite: PropTypes.bool.isRequired,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
dashboardId: PropTypes.number,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
slice: PropTypes.object,
|
||||
sliceName: PropTypes.string,
|
||||
@@ -114,9 +121,11 @@ export class ExploreChartHeader extends React.PureComponent {
|
||||
this.showReportModal = this.showReportModal.bind(this);
|
||||
this.hideReportModal = this.hideReportModal.bind(this);
|
||||
this.renderReportModal = this.renderReportModal.bind(this);
|
||||
this.fetchChartDashboardData = this.fetchChartDashboardData.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { dashboardId } = this.props;
|
||||
if (this.canAddReports()) {
|
||||
const { user, chart } = this.props;
|
||||
// this is in the case that there is an anonymous user.
|
||||
@@ -127,10 +136,40 @@ export class ExploreChartHeader extends React.PureComponent {
|
||||
chart.id,
|
||||
);
|
||||
}
|
||||
if (dashboardId) {
|
||||
this.fetchChartDashboardData();
|
||||
}
|
||||
}
|
||||
|
||||
async fetchChartDashboardData() {
|
||||
const { dashboardId, slice } = this.props;
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/chart/${slice.slice_id}`,
|
||||
});
|
||||
const chart = response.json.result;
|
||||
const dashboards = chart.dashboards || [];
|
||||
const dashboard =
|
||||
dashboardId &&
|
||||
dashboards.length &&
|
||||
dashboards.find(d => d.id === dashboardId);
|
||||
|
||||
if (dashboard && dashboard.json_metadata) {
|
||||
// setting the chart to use the dashboard custom label colors if any
|
||||
const labelColors =
|
||||
JSON.parse(dashboard.json_metadata).label_colors || {};
|
||||
const categoricalNamespace = CategoricalColorNamespace.getNamespace();
|
||||
|
||||
Object.keys(labelColors).forEach(label => {
|
||||
categoricalNamespace.setColor(label, labelColors[label]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSliceName() {
|
||||
return this.props.sliceName || t('%s - untitled', this.props.table_name);
|
||||
const { sliceName, table_name: tableName } = this.props;
|
||||
const title = sliceName || t('%s - untitled', tableName);
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
postChartFormData() {
|
||||
@@ -206,7 +245,7 @@ export class ExploreChartHeader extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, form_data: formData } = this.props;
|
||||
const { user, form_data: formData, slice } = this.props;
|
||||
const {
|
||||
chartStatus,
|
||||
chartUpdateEndTime,
|
||||
@@ -222,6 +261,14 @@ export class ExploreChartHeader extends React.PureComponent {
|
||||
return (
|
||||
<StyledHeader id="slice-header" className="panel-title-large">
|
||||
<div className="title-panel">
|
||||
{slice?.certified_by && (
|
||||
<>
|
||||
<CertifiedIcon
|
||||
certifiedBy={slice.certified_by}
|
||||
details={slice.certification_details}
|
||||
/>{' '}
|
||||
</>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={this.getSliceName()}
|
||||
canEdit={!this.props.slice || this.props.can_overwrite}
|
||||
|
||||
@@ -38,6 +38,7 @@ const propTypes = {
|
||||
can_overwrite: PropTypes.bool.isRequired,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
datasource: PropTypes.object,
|
||||
dashboardId: PropTypes.number,
|
||||
column_formats: PropTypes.object,
|
||||
containerId: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
@@ -291,6 +292,7 @@ const ExploreChartPanel = props => {
|
||||
addHistory={props.addHistory}
|
||||
can_overwrite={props.can_overwrite}
|
||||
can_download={props.can_download}
|
||||
dashboardId={props.dashboardId}
|
||||
isStarred={props.isStarred}
|
||||
slice={props.slice}
|
||||
sliceName={props.sliceName}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user