diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 388b5783a175..1922ae2b66ea 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,7 @@ --- name: Bug report about: Create a report to help us improve +labels: "#bug" --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 066b2d920a28..cb66edb2bcc7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,7 @@ --- name: Feature request about: Suggest an idea for this project +labels: "#enhancement" --- diff --git a/.github/ISSUE_TEMPLATE/sip.md b/.github/ISSUE_TEMPLATE/sip.md index 4e668b336693..7a1241c16e51 100644 --- a/.github/ISSUE_TEMPLATE/sip.md +++ b/.github/ISSUE_TEMPLATE/sip.md @@ -1,9 +1,13 @@ --- name: SIP about: Superset Improvement Proposal +labels: "#SIP" --- +*Please make sure you are familiar with the SIP process documented* +(here)[https://github.com/apache/incubator-superset/issues/5602] + ## [SIP] Proposal for XXX ### Motivation diff --git a/.github/workflows/bashlib.sh b/.github/workflows/bashlib.sh new file mode 100644 index 000000000000..5f96359ab52d --- /dev/null +++ b/.github/workflows/bashlib.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# +# 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. +# +set -e + +GITHUB_WORKSPACE=${GITHUB_WORKSPACE:-.} +ASSETS_MANIFEST="$GITHUB_WORKSPACE/superset/static/assets/manifest.json" + +# Echo only when not in parallel mode +say() { + if [[ $(echo "$INPUT_PARALLEL" | tr '[:lower:]' '[:upper:]') != 'TRUE' ]]; then + echo "$1" + fi +} + +# default command to run when the `run` input is empty +default-setup-command() { + pip-install +} + +# install python dependencies +pip-install() { + cd "$GITHUB_WORKSPACE" + + # Don't use pip cache as it doesn't seem to help much. + # cache-restore pip + + say "::group::Install Python pacakges" + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install -e ".[postgres,mysql]" + say "::endgroup::" + + # cache-save pip +} + +# prepare (lint and build) frontend code +npm-install() { + cd "$GITHUB_WORKSPACE/superset-frontend" + + cache-restore npm + + say "::group::Install npm packages" + echo "npm: $(npm --version)" + echo "node: $(node --version)" + npm ci + say "::endgroup::" + + cache-save npm +} + +build-assets() { + cd "$GITHUB_WORKSPACE/superset-frontend" + + say "::group::Build static assets" + npm run build -- --no-progress + say "::endgroup::" +} + +build-assets-cached() { + cache-restore assets + if [[ -f "$ASSETS_MANIFEST" ]]; then + echo 'Skip frontend build because static assets already exist.' + else + build-assets + cache-save assets + fi +} + +build-instrumented-assets() { + cd "$GITHUB_WORKSPACE/superset-frontend" + + say "::group::Build static assets with JS instrumented for test coverage" + cache-restore instrumented-assets + if [[ -f "$ASSETS_MANIFEST" ]]; then + echo 'Skip frontend build because instrumented static assets already exist.' + else + npm run build-instrumented -- --no-progress + cache-save instrumented-assets + fi + say "::endgroup::" +} + +setup-postgres() { + say "::group::Initialize database" + psql "postgresql://superset:superset@127.0.0.1:15432/superset" <<-EOF + DROP SCHEMA IF EXISTS sqllab_test_db; + CREATE SCHEMA sqllab_test_db; + DROP SCHEMA IF EXISTS admin_database; + CREATE SCHEMA admin_database; +EOF + say "::endgroup::" +} + +setup-mysql() { + say "::group::Initialize database" + mysql -h 127.0.0.1 -P 13306 -u root --password=root <<-EOF + DROP DATABASE IF EXISTS superset; + CREATE DATABASE superset DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci; + DROP DATABASE IF EXISTS sqllab_test_db; + CREATE DATABASE sqllab_test_db DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci; + DROP DATABASE IF EXISTS admin_database; + CREATE DATABASE admin_database DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci; + CREATE USER 'superset'@'%' IDENTIFIED BY 'superset'; + GRANT ALL ON *.* TO 'superset'@'%'; + FLUSH PRIVILEGES; +EOF + say "::endgroup::" +} + +testdata() { + cd "$GITHUB_WORKSPACE" + say "::group::Load test data" + # must specify PYTHONPATH to make `tests.superset_test_config` importable + export PYTHONPATH="$GITHUB_WORKSPACE" + superset db upgrade + superset load_test_users + superset load_examples --load-test-data + superset init + say "::endgroup::" +} + +codecov() { + say "::group::Upload code coverage" + local codecovScript="${HOME}/codecov.sh" + # download bash script if needed + if [[ ! -f "$codecovScript" ]]; then + curl -s https://codecov.io/bash > "$codecovScript" + fi + bash "$codecovScript" "$@" + say "::endgroup::" +} + +cypress-install() { + cd "$GITHUB_WORKSPACE/superset-frontend/cypress-base" + + cache-restore cypress + + say "::group::Install Cypress" + npm ci + say "::endgroup::" + + cache-save cypress +} + +# Run Cypress and upload coverage reports +cypress-run() { + cd "$GITHUB_WORKSPACE/superset-frontend/cypress-base" + + local page=$1 + local group=${2:-Default} + local cypress="./node_modules/.bin/cypress run" + local browser=${CYPRESS_BROWSER:-chrome} + + say "::group::Run Cypress for [$page]" + if [[ -z $CYPRESS_RECORD_KEY ]]; then + $cypress --spec "cypress/integration/$page" --browser "$browser" + else + # additional flags for Cypress dashboard recording + $cypress --spec "cypress/integration/$page" --browser "$browser" --record \ + --group "$group" --tag "${GITHUB_REPOSITORY},${GITHUB_EVENT_NAME}" + fi + + # don't add quotes to $record because we do want word splitting + say "::endgroup::" +} + +cypress-run-all() { + # Start Flask and run it in background + # --no-debugger means disable the interactive debugger on the 500 page + # so errors can print to stderr. + local flasklog="${HOME}/flask.log" + local port=8081 + + nohup flask run --no-debugger -p $port > "$flasklog" 2>&1 < /dev/null & + local flaskProcessId=$! + + cypress-run "*/*" + + # Upload code coverage separately so each page can have separate flags + # -c will clean existing coverage reports, -F means add flags + codecov -cF "cypress" + + # After job is done, print out Flask log for debugging + say "::group::Flask log for default run" + cat "$flasklog" + say "::endgroup::" + + # Rerun SQL Lab tests with backend persist enabled + export SUPERSET_CONFIG=tests.superset_test_config_sqllab_backend_persist + + # Restart Flask with new configs + kill $flaskProcessId + nohup flask run --no-debugger -p $port > "$flasklog" 2>&1 < /dev/null & + local flaskProcessId=$! + + cypress-run "sqllab/*" "Backend persist" + codecov -cF "cypress" + + say "::group::Flask log for backend persist" + cat "$flasklog" + say "::endgroup::" + + # make sure the program exits + kill $flaskProcessId +} diff --git a/.github/workflows/caches.js b/.github/workflows/caches.js new file mode 100644 index 000000000000..fa97106403f5 --- /dev/null +++ b/.github/workflows/caches.js @@ -0,0 +1,56 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// always use absolute directory +const workspaceDirectory = process.env.GITHUB_WORKSPACE; +const homeDirectory = process.env.HOME; + +const assetsConfig = { + path: [`${workspaceDirectory}/superset/static/assets`], + hashFiles: [ + `${workspaceDirectory}/superset-frontend/src/**/*`, + `${workspaceDirectory}/superset-frontend/*.js`, + `${workspaceDirectory}/superset-frontend/*.json`, + ], + // dont use restore keys as it may give an invalid older build + restoreKeys: '', +}; + +// Multi-layer cache definition +module.exports = { + pip: { + path: [`${homeDirectory}/.cache/pip`], + hashFiles: [`${workspaceDirectory}/requirements*.txt`], + }, + npm: { + path: [`${homeDirectory}/.npm`], + hashFiles: ['superset-frontend/package-lock.json'], + }, + assets: assetsConfig, + // use separate cache for instrumented JS files and regular assets + // one is built with `npm run build`, + // another is built with `npm run build-instrumented` + 'instrumented-assets': assetsConfig, + cypress: { + path: [`${homeDirectory}/.cache/Cypress`], + hashFiles: [ + `${workspaceDirectory}/superset-frontend/cypress-base/package-lock.json`, + ], + }, +}; diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml new file mode 100644 index 000000000000..4ecd459e38a1 --- /dev/null +++ b/.github/workflows/license-check.yml @@ -0,0 +1,22 @@ +name: License + +on: + push: + branches: [ master ] + pull_request: + +jobs: + check: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v2 + - name: Setup Java + uses: actions/setup-java@v1 + with: + java-version: 8 + - name: Generate fossa report + env: + FOSSA_API_KEY: ${{ secrets.FOSSA_API_KEY }} + run: ./scripts/fossa.sh + - name: Run license check + run: ./scripts/check_license.sh diff --git a/.github/workflows/superset-e2e.yml b/.github/workflows/superset-e2e.yml new file mode 100644 index 000000000000..1920f6c3d73a --- /dev/null +++ b/.github/workflows/superset-e2e.yml @@ -0,0 +1,57 @@ +name: E2E + +on: [push, pull_request] + +jobs: + cypress: + name: Cypress + runs-on: ubuntu-18.04 + strategy: + fail-fast: true + matrix: + browser: ['chrome'] + env: + FLASK_ENV: development + SUPERSET_CONFIG: tests.superset_test_config + SUPERSET__SQLALCHEMY_DATABASE_URI: + postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset + PYTHONPATH: ${{ github.workspace }} + REDIS_PORT: 16379 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + services: + postgres: + image: postgres:10-alpine + env: + POSTGRES_USER: superset + POSTGRES_PASSWORD: superset + ports: + - 15432:5432 + redis: + image: redis:5-alpine + ports: + - 16379:6379 + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: '3.6' + + - name: Install dependencies + uses: apache-superset/cached-dependencies@adc6f73 + with: + # Run commands in parallel does help initial installation without cache + parallel: true + run: | + npm-install && build-instrumented-assets + pip-install && setup-postgres && testdata + cypress-install + + - name: Run Cypress + uses: apache-superset/cached-dependencies@adc6f73 + env: + CYPRESS_BROWSER: ${{ matrix.browser }} + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + with: + run: cypress-run-all diff --git a/.github/workflows/superset-frontend.yml b/.github/workflows/superset-frontend.yml new file mode 100644 index 000000000000..f8ab8321f45d --- /dev/null +++ b/.github/workflows/superset-frontend.yml @@ -0,0 +1,28 @@ +name: Frontend + +on: [push, pull_request] + +jobs: + frontend-build: + name: build + runs-on: ubuntu-18.04 + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Install dependencies + uses: apache-superset/cached-dependencies@adc6f73 + with: + run: npm-install + - name: lint + working-directory: ./superset-frontend + run: | + npm run lint + npm run prettier-check + - name: unit tests + working-directory: ./superset-frontend + run: | + npm run test -- --coverage + - name: Upload code coverage + working-directory: ./superset-frontend + run: | + bash <(curl -s https://codecov.io/bash) -cF javascript diff --git a/.github/workflows/superset-python.yml b/.github/workflows/superset-python.yml new file mode 100644 index 000000000000..8d8a6a8c5f83 --- /dev/null +++ b/.github/workflows/superset-python.yml @@ -0,0 +1,179 @@ +name: Python + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-18.04 + strategy: + matrix: + python-version: [3.6] + env: + PYTHON_LINT_TARGET: setup.py superset tests + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + uses: apache-superset/cached-dependencies@adc6f73 + - name: black + run: black --check $(echo $PYTHON_LINT_TARGET) + - name: mypy + run: mypy $(echo $PYTHON_LINT_TARGET) + - name: isort + run: isort --check-only --recursive $(echo $PYTHON_LINT_TARGET) + - name: pylint + # `-j 0` run Pylint in parallel + run: pylint -j 0 superset + + docs: + runs-on: ubuntu-18.04 + strategy: + matrix: + python-version: [3.6] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + uses: apache-superset/cached-dependencies@adc6f73 + with: + run: | + pip-install + pip install -r docs/requirements.txt + - name: Copy Images + run: cp -r superset-frontend/images/ docs/_static/images/ + - name: Build documentation + run: sphinx-build -b html docs _build/html -W + + test-postgres: + runs-on: ubuntu-18.04 + strategy: + matrix: + # run unit tests in multiple version just for fun + # (3.8 is not supported yet, some dependencies need an update) + python-version: [3.6, 3.7] + env: + PYTHONPATH: ${{ github.workspace }} + SUPERSET_CONFIG: tests.superset_test_config + REDIS_PORT: 16379 + services: + postgres: + image: postgres:10-alpine + env: + POSTGRES_USER: superset + POSTGRES_PASSWORD: superset + ports: + # Use custom ports for services to avoid accidentally connecting to + # GitHub action runner's default installations + - 15432:5432 + redis: + image: redis:5-alpine + ports: + - 16379:6379 + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + uses: apache-superset/cached-dependencies@adc6f73 + with: + run: | + pip-install + setup-postgres + - name: Python unit tests (PostgreSQL) + env: + SUPERSET__SQLALCHEMY_DATABASE_URI: + postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset + run: | + ./scripts/python_tests.sh + - name: Upload code coverage + run: | + bash <(curl -s https://codecov.io/bash) -cF python + + test-mysql: + runs-on: ubuntu-18.04 + strategy: + matrix: + python-version: [3.6] + env: + PYTHONPATH: ${{ github.workspace }} + SUPERSET_CONFIG: tests.superset_test_config + REDIS_PORT: 16379 + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 13306:3306 + redis: + image: redis:5-alpine + options: --entrypoint redis-server + ports: + - 16379:6379 + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + uses: apache-superset/cached-dependencies@adc6f73 + with: + run: | + pip-install + setup-mysql + - name: Python unit tests (MySQL) + env: + SUPERSET__SQLALCHEMY_DATABASE_URI: | + mysql+mysqldb://superset:superset@127.0.0.1:13306/superset?charset=utf8mb4&binary_prefix=true + run: | + ./scripts/python_tests.sh + - name: Upload code coverage + run: | + bash <(curl -s https://codecov.io/bash) -cF python + + test-sqlite: + runs-on: ubuntu-18.04 + strategy: + matrix: + python-version: [3.6] + env: + PYTHONPATH: ${{ github.workspace }} + SUPERSET_CONFIG: tests.superset_test_config + REDIS_PORT: 16379 + services: + redis: + image: redis:5-alpine + ports: + - 16379:6379 + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + uses: apache-superset/cached-dependencies@adc6f73 + with: + run: | + pip-install + mkdir ${{ github.workspace }}/.temp + - name: Python unit tests (SQLite) + env: + SUPERSET__SQLALCHEMY_DATABASE_URI: | + sqlite:///${{ github.workspace }}/.temp/unittest.db + run: | + ./scripts/python_tests.sh + - name: Upload code coverage + run: | + bash <(curl -s https://codecov.io/bash) -cF python diff --git a/.gitignore b/.gitignore index d069eef065ea..0ccddfa4f61b 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,5 @@ apache-superset-*.tar.gz* # Translation binaries messages.mo + +docker/requirements-local.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a83a59d4e73e..22a6a0c1e6c1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ # repos: - repo: https://github.com/ambv/black - rev: 19.3b0 + rev: 19.10b0 hooks: - id: black language_version: python3 diff --git a/.pylintrc b/.pylintrc index abe341a8c72c..70ad8bc5b1c9 100644 --- a/.pylintrc +++ b/.pylintrc @@ -115,10 +115,10 @@ evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / stateme [BASIC] # Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_,d,e,v,o,l,x,ts,f +good-names=_,df,ex,f,i,id,j,k,l,o,pk,Run,ts,v,x # Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata,d,fd +bad-names=fd,foo,bar,baz,toto,tutu,tata # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 623107c5654d..000000000000 --- a/.travis.yml +++ /dev/null @@ -1,129 +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. -# -jobs: - include: - - language: python - python: 3.6 - env: - - TOXENV=fossa - install: - - pip install --upgrade pip - - pip install tox - - language: python - python: 3.6 - env: - - TOXENV=license-check - - TRAVIS_CACHE=$HOME/.travis_cache/ - addons: - apt: - packages: - - openjdk-8-jdk - install: - - pip install --upgrade pip - - pip install tox - - language: python - python: 3.6 - env: TOXENV=cypress-dashboard - services: - - redis-server - before_install: - - nvm install 10.14.2 - - language: python - python: 3.6 - env: TOXENV=cypress-explore - services: - - redis-server - before_install: - - nvm install 10.14.2 - - language: python - python: 3.6 - env: TOXENV=cypress-sqllab - services: - - redis-server - before_install: - - nvm install 10.14.2 - - language: python - python: 3.6 - env: TOXENV=py36-mysql - services: - - mysql - - redis-server - before_script: - - mysql -u root -e "DROP DATABASE IF EXISTS superset; CREATE DATABASE superset DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci" - - mysql -u root -e "DROP DATABASE IF EXISTS sqllab_test_db; CREATE DATABASE sqllab_test_db DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci" - - mysql -u root -e "DROP DATABASE IF EXISTS admin_database; CREATE DATABASE admin_database DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci" - - mysql -u root -e "CREATE USER 'mysqluser'@'localhost' IDENTIFIED BY 'mysqluserpassword';" - - mysql -u root -e "GRANT ALL ON *.* TO 'mysqluser'@'localhost';" - - language: python - env: TOXENV=javascript - before_install: - - nvm install 10.14.2 - - language: python - python: 3.6 - env: TOXENV=black - - language: python - python: 3.6 - env: TOXENV=isort - - language: python - python: 3.6 - env: TOXENV=mypy - - language: python - python: 3.6 - env: TOXENV=py36-sqlite - services: - - redis-server - - language: python - python: 3.6 - env: TOXENV=py36-postgres - services: - - postgresql - - redis-server - before_script: - - psql -U postgres -c "DROP DATABASE IF EXISTS superset;" - - psql -U postgres -c "CREATE DATABASE superset;" - - psql -U postgres superset -c "DROP SCHEMA IF EXISTS sqllab_test_db;" - - psql -U postgres superset -c "CREATE SCHEMA sqllab_test_db;" - - psql -U postgres superset -c "DROP SCHEMA IF EXISTS admin_database;" - - psql -U postgres superset -c "CREATE SCHEMA admin_database;" - - psql -U postgres -c "CREATE USER postgresuser WITH PASSWORD 'pguserpassword';" - - psql -U postgres superset -c "GRANT ALL PRIVILEGES ON SCHEMA sqllab_test_db to postgresuser"; - - psql -U postgres superset -c "GRANT ALL PRIVILEGES ON SCHEMA admin_database to postgresuser"; - - language: python - python: 3.6 - env: TOXENV=pylint - - language: python - python: 3.6 - env: TOXENV=docs - -script: - - tox -after_success: - - codecov -cache: - pip: true - directories: - - ~/.npm - - ~/.cache - - ~/.travis_cache/ - - superset-frontend/.terser-plugin-cache/ -addons: - apt: - packages: - - libgconf-2-4 -install: - - pip install --upgrade pip - - pip install codecov tox diff --git a/CHANGELOG.md b/CHANGELOG.md index be42001bf4e4..31868df051e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,258 @@ under the License. --> ## Change Log +### 0.36.0 (2020/04/02 07:57 +00:00) +- [#9436](https://github.com/apache/incubator-superset/pull/9436) Add check for SSL certificate and add form validators (#9436) (@villebro) +- [#9428](https://github.com/apache/incubator-superset/pull/9428) [fix]some translation not work better (#9428) (@venter-zhu) +- [#9425](https://github.com/apache/incubator-superset/pull/9425) fix pagination for list views (#9425) (@nytai) +- [#9401](https://github.com/apache/incubator-superset/pull/9401) [fix] dashboard filter indicator no showing single number value (#9401) (@graceguo-supercat) +- [#9408](https://github.com/apache/incubator-superset/pull/9408) [fix] allow force refresh for No Results chart (#9408) (@graceguo-supercat) +- [#9400](https://github.com/apache/incubator-superset/pull/9400) Build: fix hot reload for charts (#9400) (@ktmud) +- [#9417](https://github.com/apache/incubator-superset/pull/9417) [dashboards] Fix, API update slug uniqueness refusing empty string (#9417) (@dpgaspar) +- [#9411](https://github.com/apache/incubator-superset/pull/9411) [mypy] Enforcing typing for charts (#9411) (@john-bodley) +- [#9413](https://github.com/apache/incubator-superset/pull/9413) [dependency] Fix, Bump FAB to 2.3.1 (#9413) (@dpgaspar) +- [#9382](https://github.com/apache/incubator-superset/pull/9382) [fix] Fixing cache key inconsistencies (#9382) (@john-bodley) +- [#9396](https://github.com/apache/incubator-superset/pull/9396) feat: add SSL certificate validation for Druid (#9396) (@villebro) +- [#9385](https://github.com/apache/incubator-superset/pull/9385) Mirgrating unique Partition chart controls (#9385) (@rusackas) +- [#9397](https://github.com/apache/incubator-superset/pull/9397) [sip-15] fix messaging (#9397) (@john-bodley) +- [#9387](https://github.com/apache/incubator-superset/pull/9387) [charts] New, bulk delete API endpoint (#9387) (@dpgaspar) +- [#9367](https://github.com/apache/incubator-superset/pull/9367) [dataset] New, export API endpoint (#9367) (@dpgaspar) +- [#9373](https://github.com/apache/incubator-superset/pull/9373) migrating controls (#9373) (@rusackas) +- [#9333](https://github.com/apache/incubator-superset/pull/9333) build: use manifest hooks for dev server proxy and fix hot reload for charts (#9333) (@ktmud) +- [#9368](https://github.com/apache/incubator-superset/pull/9368) Migrating horizon controls (#9368) (@rusackas) +- [#9374](https://github.com/apache/incubator-superset/pull/9374) migrating unique controls (#9374) (@rusackas) +- [#9372](https://github.com/apache/incubator-superset/pull/9372) upgrade to react-bootstrap v0.33.1 (#9372) (@suddjian) +- [#9392](https://github.com/apache/incubator-superset/pull/9392) Migrating unique BoxPlot controls (#9392) (@rusackas) +- [#9388](https://github.com/apache/incubator-superset/pull/9388) Migrating unique Table controls (#9388) (@rusackas) +- [#9386](https://github.com/apache/incubator-superset/pull/9386) migrating controls 🎛 (#9386) (@rusackas) +- [#9375](https://github.com/apache/incubator-superset/pull/9375) [cache] Cleaning up viz/cache logic (#9375) (@john-bodley) +- [#9350](https://github.com/apache/incubator-superset/pull/9350) [dashboard] handle markdown error (#9350) (@graceguo-supercat) +- [#9391](https://github.com/apache/incubator-superset/pull/9391) Removing WordCloud controls from CONTRIBUTING.md (#9391) (@rusackas) +- [#9381](https://github.com/apache/incubator-superset/pull/9381) fix: [dashboard] add row padding (#9381) (@nytai) +- [#9261](https://github.com/apache/incubator-superset/pull/9261) Update MANIFEST.in (#9261) (@amancevice) +- [#9359](https://github.com/apache/incubator-superset/pull/9359) Migrating unique DirectedForce controls (#9359) (@rusackas) +- [#9383](https://github.com/apache/incubator-superset/pull/9383) fix a typo in set prop value (#9383) (@graceguo-supercat) +- [#9345](https://github.com/apache/incubator-superset/pull/9345) [explore view] fix long query issue from Run in SQL LAB Button (#9345) (@graceguo-supercat) +- [#9377](https://github.com/apache/incubator-superset/pull/9377) [sip-15] Fixing typo in docstring (#9377) (@john-bodley) +- [#9351](https://github.com/apache/incubator-superset/pull/9351) fix: don't parseFloat when the *already numeric* value ends in a decimal point (#9351) (@rusackas) +- [#9360](https://github.com/apache/incubator-superset/pull/9360) Migrate unique Heatmap controls (#9360) (@villebro) +- [#9357](https://github.com/apache/incubator-superset/pull/9357) Adding requirements-local.txt support (#9357) (@craig-rueda) +- [#9268](https://github.com/apache/incubator-superset/pull/9268) [dataset] columns and metrics API (nested) (#9268) (@dpgaspar) +- [#9310](https://github.com/apache/incubator-superset/pull/9310) Add global install of webpack and webpack-cli to docker-compose (#9310) (@willbarrett) +- [#9329](https://github.com/apache/incubator-superset/pull/9329) [charts] Refactor API using SIP-35 (#9329) (@dpgaspar) +- [#9340](https://github.com/apache/incubator-superset/pull/9340) feat: [explore] don't save filters inherited from a dashboard (#9340) (@mistercrunch) +- [#9352](https://github.com/apache/incubator-superset/pull/9352) Treemap controls migration (#9352) (@rusackas) +- [#9358](https://github.com/apache/incubator-superset/pull/9358) migrating unique EventFlow controls (#9358) (@rusackas) +- [#9355](https://github.com/apache/incubator-superset/pull/9355) Cal heatmap controls migration (#9355) (@rusackas) +- [#9338](https://github.com/apache/incubator-superset/pull/9338) feat: [SQLLAB] add checkbox to control autocomplete (#9338) (@nytai) +- [#9339](https://github.com/apache/incubator-superset/pull/9339) [config] Fixing GET_FEATURE_FLAGS_FUNC example (#9339) (@john-bodley) +- [#9332](https://github.com/apache/incubator-superset/pull/9332) refactor: remove settooltip (#9332) (@kristw) +- [#9343](https://github.com/apache/incubator-superset/pull/9343) fix: suburst chart when secondary metric is defined (#9343) (@villebro) +- [#9331](https://github.com/apache/incubator-superset/pull/9331) [requirements] Telling Celery 4.4.1 it is not welcome here (#9331) (@john-bodley) +- [#9315](https://github.com/apache/incubator-superset/pull/9315) [dashboard] Refactor API using SIP-35 (#9315) (@dpgaspar) +- [#9325](https://github.com/apache/incubator-superset/pull/9325) feat: bump deckgl plugin version (#9325) (@kristw) +- [#9326](https://github.com/apache/incubator-superset/pull/9326) Build: optimize frontend build configs to improve superset-ui-plugin dev experience (#9326) (@ktmud) +- [#9330](https://github.com/apache/incubator-superset/pull/9330) [chart] fix, bulk delete endpoint and error message (#9330) (@nytai) +- [#9211](https://github.com/apache/incubator-superset/pull/9211) show edit modal on dashboards list view (#9211) (@suddjian) +- [#9277](https://github.com/apache/incubator-superset/pull/9277) Revert "[requirements] Bumpy Celery (#9277)" (#9323) (@etr2460) +- [#9322](https://github.com/apache/incubator-superset/pull/9322) fix: handle list of lists from fetch_data (#9322) (@villebro) +- [#9319](https://github.com/apache/incubator-superset/pull/9319) fix: cannot assign to read only property exports of object (#9319) (@kristw) +- [#9311](https://github.com/apache/incubator-superset/pull/9311) [cache warm_up] warm_up slice with dashboard default_filters (#9311) (@graceguo-supercat) +- [#8940](https://github.com/apache/incubator-superset/pull/8940) Add Iran to Country Visualization (#8940) (@ali-bahjati) +- [#9296](https://github.com/apache/incubator-superset/pull/9296) chore: allow webpack-dev-server proxy to any destination (#9296) (@ktmud) +- [#9318](https://github.com/apache/incubator-superset/pull/9318) bump FAB to 2.3.0 (#9318) (@nytai) +- [#9316](https://github.com/apache/incubator-superset/pull/9316) fix: remove character set and collate column info by default (#9316) (@villebro) +- [#9314](https://github.com/apache/incubator-superset/pull/9314) fix: big number to handle NULL as it did in the past (#9314) (@mistercrunch) +- [#9312](https://github.com/apache/incubator-superset/pull/9312) [datasets] fix typo (#9312) (@nytai) +- [#9309](https://github.com/apache/incubator-superset/pull/9309) fix: add saved metrics to point size metric dropdown in deckgl scatterplot (#9309) (@villebro) +- [#9287](https://github.com/apache/incubator-superset/pull/9287) [sqllab] fix exception caused by casting string to int with psycopg2 (#9287) (@nytai) +- [#9305](https://github.com/apache/incubator-superset/pull/9305) Fixed two typos in the README (#9305) (@mfharding) +- [#9267](https://github.com/apache/incubator-superset/pull/9267) [Charts] Use the Edit Properties modal throughout React views (#9267) (@suddjian) +- [#9299](https://github.com/apache/incubator-superset/pull/9299) fix: bump click in setup.py and requirements.txt (#9299) (@villebro) +- [#9197](https://github.com/apache/incubator-superset/pull/9197) [datasets] new, listview (react) (#9197) (@nytai) +- [#9284](https://github.com/apache/incubator-superset/pull/9284) Reduce dashboard bootstrap payload (#9284) (@etr2460) +- [#9285](https://github.com/apache/incubator-superset/pull/9285) Docker-Compose Memory Issue Fix? (#9285) (@craig-rueda) +- [#9290](https://github.com/apache/incubator-superset/pull/9290) [SIP-36] Migrate RunQueryActionButton.jsx to RunQueryActionButton.tsx (#9290) (#9291) (@asif-ir) +- [#9283](https://github.com/apache/incubator-superset/pull/9283) [api] Fix, related fields need to be explicitly defined (#9283) (@dpgaspar) +- [#9279](https://github.com/apache/incubator-superset/pull/9279) [dashboard][api] Fix, PUT publish/draft to not clean slug and owners (#9279) (@dpgaspar) +- [#9286](https://github.com/apache/incubator-superset/pull/9286) fix: bump legacy-table-chart to 0.11.20 (#9286) (@ktmud) +- [#9277](https://github.com/apache/incubator-superset/pull/9277) [requirements] Bumpy Celery (#9277) (@john-bodley) +- [#9275](https://github.com/apache/incubator-superset/pull/9275) fix(table-chart): bump legacy-table-chart to 0.11.18 (#9275) (@ktmud) +- [#9274](https://github.com/apache/incubator-superset/pull/9274) fix: remove duplicate metric from bullet chart (#9274) (@villebro) +- [#9272](https://github.com/apache/incubator-superset/pull/9272) fix: add connection testing params for snowflake (#9272) (@villebro) +- [#9271](https://github.com/apache/incubator-superset/pull/9271) [fix] copy filter_scopes with duplicate charts (#9271) (@graceguo-supercat) +- [#9107](https://github.com/apache/incubator-superset/pull/9107) feat: add rolling window support to 'Big Number with Trendline' viz (#9107) (@mistercrunch) +- [#9269](https://github.com/apache/incubator-superset/pull/9269) fix: upgrade legacy table chart to 0.11.17 (#9269) (@ktmud) +- [#9255](https://github.com/apache/incubator-superset/pull/9255) fix: change database save in DatasourceEditor (#9255) (@mistercrunch) +- [#9263](https://github.com/apache/incubator-superset/pull/9263) Adds default username and password created at installation to documentation (#9263) (@willbarrett) +- [#9264](https://github.com/apache/incubator-superset/pull/9264) removing safari "fix" for ACE editor font width jank. (#9264) (@rusackas) +- [#9259](https://github.com/apache/incubator-superset/pull/9259) New entry into superset user (#9259) (@Better-Boy) +- [#9243](https://github.com/apache/incubator-superset/pull/9243) [log] Add dashboard_id param to explore_json request (#9243) (@graceguo-supercat) +- [#9119](https://github.com/apache/incubator-superset/pull/9119) Update PyArrow to 0.16.0 (#9119) (@robdiciuccio) +- [#9250](https://github.com/apache/incubator-superset/pull/9250) [webpack] fix copying images when running dev server (#9250) (@nytai) +- [#9129](https://github.com/apache/incubator-superset/pull/9129) [datasets] new, API using command pattern (#9129) (@dpgaspar) +- [#9247](https://github.com/apache/incubator-superset/pull/9247) [chart] fix, datasource link in listview (#9247) (@nytai) +- [#9254](https://github.com/apache/incubator-superset/pull/9254) fix: update release testing FLASK_APP param (#9254) (@villebro) +- [#9252](https://github.com/apache/incubator-superset/pull/9252) Add PubNub to list of organizations that use Superset (#9252) (@jzucker2) +- [#9235](https://github.com/apache/incubator-superset/pull/9235) [fix] use filter_scopes in dashboard warmup strategy (#9235) (@graceguo-supercat) +- [#9248](https://github.com/apache/incubator-superset/pull/9248) Bump node from v10 to v12 in release Dockerfiles (#9248) (@kristw) +- [#9241](https://github.com/apache/incubator-superset/pull/9241) [build] Bump superset-ui packages and update build (#9241) (@etr2460) +- [#9246](https://github.com/apache/incubator-superset/pull/9246) [UPDATING] Adding notes regarding #8867 (#9246) (@villebro) +- [#9238](https://github.com/apache/incubator-superset/pull/9238) Add option to specify type specific date truncation functions (#9238) (@villebro) +- [#9207](https://github.com/apache/incubator-superset/pull/9207) Introducing Inter UI & Fira typefaces (#9207) (@etr2460) +- [#9215](https://github.com/apache/incubator-superset/pull/9215) fix: choose language link for local dev (#9215) (@etr2460) +- [#9240](https://github.com/apache/incubator-superset/pull/9240) fix: Oracle fetch_query and datetime conversion (#9240) (@villebro) +- [#9161](https://github.com/apache/incubator-superset/pull/9161) fix: share column type matching between model and result set (#9161) (@villebro) +- [#9232](https://github.com/apache/incubator-superset/pull/9232) [security] Fix, let admin's be able to reset user passwords on AUTH_DB (#9232) (@dpgaspar) +- [#8867](https://github.com/apache/incubator-superset/pull/8867) Make schema name for the CTA queries and limit configurable (#8867) (@bkyryliuk) +- [#9205](https://github.com/apache/incubator-superset/pull/9205) [api] enable CSRF by default (#9205) (@dpgaspar) +- [#9220](https://github.com/apache/incubator-superset/pull/9220) [SQL Lab] Implement refetch results button properly (#9220) (@etr2460) +- [#9218](https://github.com/apache/incubator-superset/pull/9218) Prevent database connections to sqlite (#9218) (@suddjian) +- [#9224](https://github.com/apache/incubator-superset/pull/9224) refactor copy filter_scopes and add tests (#9224) (@graceguo-supercat) +- [#9219](https://github.com/apache/incubator-superset/pull/9219) [fix] Adding SIP-15 support for the query context (#9219) (@john-bodley) +- [#9212](https://github.com/apache/incubator-superset/pull/9212) [dashboard, chart] fix ordering and filtering in listviews (#9212) (@nytai) +- [#9213](https://github.com/apache/incubator-superset/pull/9213) [fix] remove chart id from filter_scopes metadata if chart is not in dash anymore (#9213) (@graceguo-supercat) +- [#9196](https://github.com/apache/incubator-superset/pull/9196) [Bug Fix] Returning timeseries_limit_metric in table viz get_data (#9196) (@michellethomas) +- [#9203](https://github.com/apache/incubator-superset/pull/9203) [annotation] upgrade chart plugin version (#9203) (@graceguo-supercat) +- [#9202](https://github.com/apache/incubator-superset/pull/9202) [dashboard perf logging] add dashboard url anchor component id (#9202) (@graceguo-supercat) +- [#9106](https://github.com/apache/incubator-superset/pull/9106) chore: run 'npm audit fix' to fix 2 vulnerabilities (#9106) (@mistercrunch) +- [#9063](https://github.com/apache/incubator-superset/pull/9063) Removing (unused?) Victory theme file (#9063) (@rusackas) +- [#9189](https://github.com/apache/incubator-superset/pull/9189) Upgrade typescript to 3.8.2 (#9189) (@ktmud) +- [#9133](https://github.com/apache/incubator-superset/pull/9133) [config] Disable FAB's permission and view menus views (#9133) (@dpgaspar) +- [#9185](https://github.com/apache/incubator-superset/pull/9185) docs: update CONTRIBUTING with TypeScript details from [SIP-36] (#9185) (@etr2460) +- [#9180](https://github.com/apache/incubator-superset/pull/9180) [SIP-36] Migrate setupApp.js to setupApp.ts (#9180) (@etr2460) +- [#9188](https://github.com/apache/incubator-superset/pull/9188) [dashboard] fix filter_scopes when copy dashboard with duplicate_slices (#9188) (@graceguo-supercat) +- [#9165](https://github.com/apache/incubator-superset/pull/9165) Bump FAB to 2.2.4 (#9165) (@dpgaspar) +- [#9086](https://github.com/apache/incubator-superset/pull/9086) adds FAB style filter types (#9086) (@nytai) +- [#9183](https://github.com/apache/incubator-superset/pull/9183) forcing fixed width fonts on ace editor (fixes #9095) (#9183) (@rusackas) +- [#9167](https://github.com/apache/incubator-superset/pull/9167) [log] Set detailed query info to log debug level (#9167) (@dpgaspar) +- [#9178](https://github.com/apache/incubator-superset/pull/9178) [core] Fix, sanitize errors returned from testconn (#9178) (@dpgaspar) +- [#9184](https://github.com/apache/incubator-superset/pull/9184) docs: remove focus on Druid in README.md (#9184) (@mistercrunch) +- [#9191](https://github.com/apache/incubator-superset/pull/9191) Make JSX Menu links open in new tab (#9191) (@etr2460) +- [#8699](https://github.com/apache/incubator-superset/pull/8699) [SIP-29] Add support for row-level security (#8699) (@altef) +- [#9181](https://github.com/apache/incubator-superset/pull/9181) Infer SQL_LAB QuerySource from referrer (#9181) (@etr2460) +- [#9173](https://github.com/apache/incubator-superset/pull/9173) [fix] SQL query source (#9173) (@john-bodley) +- [#9172](https://github.com/apache/incubator-superset/pull/9172) deprecate tslint and configure eslint for typescript (#9172) (@nytai) +- [#9144](https://github.com/apache/incubator-superset/pull/9144) [database] Fix, tables API endpoint (#9144) (@dpgaspar) +- [#9146](https://github.com/apache/incubator-superset/pull/9146) [dashboard] clean up usage for old filter immune metadata (#9146) (@graceguo-supercat) +- [#9120](https://github.com/apache/incubator-superset/pull/9120) Add feature flags to control query sharing, KV exposure (#9120) (@willbarrett) +- [#9145](https://github.com/apache/incubator-superset/pull/9145) [dashboard] use filter_scopes metadata when import old dashboard (#9145) (@graceguo-supercat) +- [#9162](https://github.com/apache/incubator-superset/pull/9162) [SIP-36] Migrate Link.jsx to Link.tsx (#9162) (@etr2460) +- [#9163](https://github.com/apache/incubator-superset/pull/9163) filter out markdown containing XSS (#9163) (@nytai) +- [#9138](https://github.com/apache/incubator-superset/pull/9138) [mypy] Enforcing typing for db_engine_specs (#9138) (@john-bodley) +- [#8925](https://github.com/apache/incubator-superset/pull/8925) Add release refinements from 0.35.2 release (#8925) (@villebro) +- [#9142](https://github.com/apache/incubator-superset/pull/9142) Support human readable datetime type for PinotDB (#9142) (@fx19880617) +- [#9139](https://github.com/apache/incubator-superset/pull/9139) Catch TypeError on PyArrow array instantiation (#9139) (@robdiciuccio) +- [#9122](https://github.com/apache/incubator-superset/pull/9122) [fix] Fix table viz column order (#9122) (@john-bodley) +- [#9150](https://github.com/apache/incubator-superset/pull/9150) [mypy] Disallowing implicit optional (#9150) (@john-bodley) +- [#9149](https://github.com/apache/incubator-superset/pull/9149) fix adhoc metric bug in chord diagram (#9149) (@villebro) +- [#9102](https://github.com/apache/incubator-superset/pull/9102) [sqllab] fix: return pandas records in execute_sql_statements (#9102) (@nytai) +- [#8658](https://github.com/apache/incubator-superset/pull/8658) fix: handle duplicate groupby keys (#8658) (@mistercrunch) +- [#9109](https://github.com/apache/incubator-superset/pull/9109) [migration] metadata for dashboard filters (#9109) (@graceguo-supercat) +- [#9140](https://github.com/apache/incubator-superset/pull/9140) [dashboard] remove loading spinner in missing chart holder (#9140) (@graceguo-supercat) +- [#9054](https://github.com/apache/incubator-superset/pull/9054) [database] new, select star API migration (#9054) (@dpgaspar) +- [#9134](https://github.com/apache/incubator-superset/pull/9134) [charts] Fix, double registration of charts API (#9134) (@dpgaspar) +- [#9114](https://github.com/apache/incubator-superset/pull/9114) [docker] fix, Dockerfile for frontend builds (#9114) (@suddjian) +- [#9117](https://github.com/apache/incubator-superset/pull/9117) Bump FAB to 2.2.3 (#9117) (@dpgaspar) +- [#9121](https://github.com/apache/incubator-superset/pull/9121) [logging] Add data_age for cached chart (#9121) (@graceguo-supercat) +- [#9098](https://github.com/apache/incubator-superset/pull/9098) SIP-32: Moving frontend code to the base of the repo (#9098) (@suddjian) +- [#9043](https://github.com/apache/incubator-superset/pull/9043) Add support for Cockroach DB (#9043) (@derari) +- [#9099](https://github.com/apache/incubator-superset/pull/9099) Moving away from using the root logger everywhere (#9099) (@craig-rueda) +- [#9081](https://github.com/apache/incubator-superset/pull/9081) [dashboard] Fix for dashboard edit modal, loading user list (#9081) (@suddjian) +- [#9091](https://github.com/apache/incubator-superset/pull/9091) [datasources] Fix, Prevent gamma user's from accessing save datasources (#9091) (@dpgaspar) +- [#9096](https://github.com/apache/incubator-superset/pull/9096) SQL Lab: Use numpy structured arrays, fallback to JSON serialization (#9096) (@robdiciuccio) +- [#9097](https://github.com/apache/incubator-superset/pull/9097) [tox] Allowing running of specific tests (#9097) (@john-bodley) +- [#9044](https://github.com/apache/incubator-superset/pull/9044) [table] [columns] remove generic checkbox API (#9044) (@dpgaspar) +- [#9088](https://github.com/apache/incubator-superset/pull/9088) [dashboard] Fix metadata state (#9088) (@suddjian) +- [#9093](https://github.com/apache/incubator-superset/pull/9093) [fix] Temporary filename for CSV upload to Hive (#9093) (@john-bodley) +- [#8999](https://github.com/apache/incubator-superset/pull/8999) [chart] new, list view (react) (#8999) (@nytai) +- [#9087](https://github.com/apache/incubator-superset/pull/9087) [fix] Add Auto Refresh Dashboard user event into dashboard logging (#9087) (@graceguo-supercat) +- [#9078](https://github.com/apache/incubator-superset/pull/9078) Wrap tagging endpoints in a feature flag (disabled by default) (#9078) (@willbarrett) +- [#9046](https://github.com/apache/incubator-superset/pull/9046) [query] deprecate can_only_access_owned_queries (#9046) (@dpgaspar) +- [#9056](https://github.com/apache/incubator-superset/pull/9056) Do not show stacktraces on some intentionally-thrown errors (#9056) (@willbarrett) +- [#9082](https://github.com/apache/incubator-superset/pull/9082) [fix] Issue with previously defined SQL configuration (#9082) (@john-bodley) +- [#9047](https://github.com/apache/incubator-superset/pull/9047) [csv upload] Use python's named temp file (#9047) (@dpgaspar) +- [#9051](https://github.com/apache/incubator-superset/pull/9051) [explore] Modal to edit chart properties (#9051) (@suddjian) +- [#9069](https://github.com/apache/incubator-superset/pull/9069) [docs] add a link to versioned docs in the docs (#9069) (@mistercrunch) +- [#9076](https://github.com/apache/incubator-superset/pull/9076) Add Preset, Inc. to companies using Superset (#9076) (@willbarrett) +- [#9070](https://github.com/apache/incubator-superset/pull/9070) [logging] Add flag for document visibility (#9070) (@graceguo-supercat) +- [#9060](https://github.com/apache/incubator-superset/pull/9060) [domain sharding] Freeup main domain when domain sharding is enabled (#9060) (@graceguo-supercat) +- [#9017](https://github.com/apache/incubator-superset/pull/9017) [sip-15] Enabling SIP-15 by default (#9017) (@john-bodley) +- [#9075](https://github.com/apache/incubator-superset/pull/9075) add Dragonpass Com. Ltd. (#9075) (@zhxjdwh) +- [#9065](https://github.com/apache/incubator-superset/pull/9065) [sqla] Fixing ORDER BY logic (#9065) (@john-bodley) +- [#9068](https://github.com/apache/incubator-superset/pull/9068) update organisation name from WPSemantix to timbr.ai (#9068) (@semantiDan) +- [#9064](https://github.com/apache/incubator-superset/pull/9064) [SQL Lab] Improve autocomplete performance (#9064) (@etr2460) +- [#9062](https://github.com/apache/incubator-superset/pull/9062) [fix] Ensure that is_adhoc_metric returns a boolean (#9062) (@john-bodley) +- [#9023](https://github.com/apache/incubator-superset/pull/9023) LESS is more (#9023) (@rusackas) +- [#9058](https://github.com/apache/incubator-superset/pull/9058) [Viz/Query] Improve logging around cache hits (#9058) (@etr2460) +- [#9059](https://github.com/apache/incubator-superset/pull/9059) [SQL Lab] Remove space after schema autocomplete (#9059) (@etr2460) +- [#9052](https://github.com/apache/incubator-superset/pull/9052) [docs] update README.md Peak AI (#9052) (@azhar22k) +- [#9050](https://github.com/apache/incubator-superset/pull/9050) [UPDATING] Add metadata cache changes to 0.29.0 (#9050) (@john-bodley) +- [#9018](https://github.com/apache/incubator-superset/pull/9018) Add revert guidelines to CONTRIBUTING.md (#9018) (@willbarrett) +- [#9041](https://github.com/apache/incubator-superset/pull/9041) [sqllab] Showing schema length only when schema selected (#9041) (@john-bodley) +- [#9031](https://github.com/apache/incubator-superset/pull/9031) [fix] Pivot table metric ordering (#9031) (@john-bodley) +- [#8527](https://github.com/apache/incubator-superset/pull/8527) Avoid fetch fav dashboard stat not logged in (#8527) (@aspedrosa) +- [#9049](https://github.com/apache/incubator-superset/pull/9049) Remove endpoints allowing arbitrary cache access (#9049) (@willbarrett) +- [#9002](https://github.com/apache/incubator-superset/pull/9002) [database] new, API table metadata (#9002) (@dpgaspar) +- [#8982](https://github.com/apache/incubator-superset/pull/8982) [api] fix, set default columns to just id when not defined (#8982) (@dpgaspar) +- [#9038](https://github.com/apache/incubator-superset/pull/9038) Remove redirect endpoint /superset/explorev2 (#9038) (@willbarrett) +- [#9040](https://github.com/apache/incubator-superset/pull/9040) [fix] Adding show to FAB CRUD set (#9040) (@john-bodley) +- [#9007](https://github.com/apache/incubator-superset/pull/9007) Serialize nested columns as JSON strings (#9007) (@robdiciuccio) +- [#9036](https://github.com/apache/incubator-superset/pull/9036) [routes] Re-adding FAB API routes for TableColumnInlineView (#9036) (@john-bodley) +- [#9035](https://github.com/apache/incubator-superset/pull/9035) [routes] Re-adding FAB API routes for SqlMetricInlineView and TableModelView (#9035) (@john-bodley) +- [#9030](https://github.com/apache/incubator-superset/pull/9030) [fix] Reverting metic logic from #8901 (#9030) (@john-bodley) +- [#9025](https://github.com/apache/incubator-superset/pull/9025) [dashboard] fix, add config to optionally enable react replacement fo… (#9025) (@nytai) +- [#8979](https://github.com/apache/incubator-superset/pull/8979) [dashboard] new, bulk actions for delete & export (#8979) (@nytai) +- [#9026](https://github.com/apache/incubator-superset/pull/9026) [refactor] Centralizing custom Python types (#9026) (@john-bodley) +- [#8993](https://github.com/apache/incubator-superset/pull/8993) [log] fix, log model view permissions (#8993) (@dpgaspar) +- [#9020](https://github.com/apache/incubator-superset/pull/9020) [Caching] Ensure cache is always created (#9020) (@etr2460) +- [#9015](https://github.com/apache/incubator-superset/pull/9015) [dashboard] fix, enable info endpoint (#9015) (@nytai) +- [#9019](https://github.com/apache/incubator-superset/pull/9019) [SQL Lab] Cache function names query (#9019) (@etr2460) +- [#9010](https://github.com/apache/incubator-superset/pull/9010) [i18n] enable spanish (#9010) (@serenajiang) +- [#9011](https://github.com/apache/incubator-superset/pull/9011) [fix] Ensure sunburst column ordering adheres to hierarchy (#9011) (@john-bodley) +- [#9012](https://github.com/apache/incubator-superset/pull/9012) [SQL Lab] Add function names to autocomplete (#9012) (@etr2460) +- [#8984](https://github.com/apache/incubator-superset/pull/8984) Z index registry / clean-up (#8984) (@rusackas) +- [#9009](https://github.com/apache/incubator-superset/pull/9009) [perf_logging] Add is_cached status when chart has error (#9009) (@graceguo-supercat) +- [#9008](https://github.com/apache/incubator-superset/pull/9008) [SQL Lab] Disable autocomplete when typing numbers (#9008) (@etr2460) +- [#9006](https://github.com/apache/incubator-superset/pull/9006) [fix] pydruid export_pandas (#9006) (@john-bodley) +- [#8998](https://github.com/apache/incubator-superset/pull/8998) docs: remove reference to Panoramix and Caravel (#8998) (@mistercrunch) +- [#9004](https://github.com/apache/incubator-superset/pull/9004) Bump FAB to 2.2.2 (#9004) (@dpgaspar) +- [#8960](https://github.com/apache/incubator-superset/pull/8960) fix: shut off unneeded endpoints (#8960) (@mistercrunch) +- [#8988](https://github.com/apache/incubator-superset/pull/8988) Timing and radii (#8988) (@rusackas) +- [#8992](https://github.com/apache/incubator-superset/pull/8992) Bump requirements.txt to what setup.py would pull in (#8992) (@villebro) +- [#8995](https://github.com/apache/incubator-superset/pull/8995) [druid] Making scaning/refreshing Druid datasource view items optional (#8995) (@john-bodley) +- [#8997](https://github.com/apache/incubator-superset/pull/8997) [SQL Lab] Open request access link in a new tab (#8997) (@etr2460) +- [#8996](https://github.com/apache/incubator-superset/pull/8996) [druid] make cluster_name editable (#8996) (@serenajiang) +- [#8985](https://github.com/apache/incubator-superset/pull/8985) Bump pandas to 0.25.3 (#8985) (@villebro) +- [#8972](https://github.com/apache/incubator-superset/pull/8972) [dashboards] New, API for Bulk delete (#8972) (@dpgaspar) +- [#8917](https://github.com/apache/incubator-superset/pull/8917) [charts] New, REST API (#8917) (@dpgaspar) +- [#8817](https://github.com/apache/incubator-superset/pull/8817) [sip-15] Displaying endpoints for all start/end time ranges (#8817) (@john-bodley) +- [#8901](https://github.com/apache/incubator-superset/pull/8901) fix: add datasource.changed_on to cache_key (#8901) (@villebro) +- [#8958](https://github.com/apache/incubator-superset/pull/8958) [docs] Fix CORS section in installation (#8958) (@graceguo-supercat) +- [#8845](https://github.com/apache/incubator-superset/pull/8845) [dashboard] New, list view (react) (#8845) (@nytai) +- [#8974](https://github.com/apache/incubator-superset/pull/8974) fix empty slug breaking url (#8974) (@suddjian) +- [#8967](https://github.com/apache/incubator-superset/pull/8967) Refactor sql editor autocomplete (#8967) (@etr2460) +- [#8941](https://github.com/apache/incubator-superset/pull/8941) [dashboards] New, export api (#8941) (@dpgaspar) +- [#8971](https://github.com/apache/incubator-superset/pull/8971) Add changelog for 0.35.2 (#8971) (@villebro) +- [#8969](https://github.com/apache/incubator-superset/pull/8969) docs: fix bad extras_require reference (#8969) (@mistercrunch) +- [#8964](https://github.com/apache/incubator-superset/pull/8964) Fixing RewardGateway URL (https://rewardgateway.com/ gave a cert error) (#8964) (@craig-rueda) +- [#8966](https://github.com/apache/incubator-superset/pull/8966) fix: lighten CSS border for data preview table (#8966) (@mistercrunch) +- [#8876](https://github.com/apache/incubator-superset/pull/8876) [dashboard] Modal for editing dashboard properties & metadata (#8876) (@suddjian) +- [#8949](https://github.com/apache/incubator-superset/pull/8949) [filter_box] Fix ; separated filter_box default values (#8949) (@graceguo-supercat) +- [#8950](https://github.com/apache/incubator-superset/pull/8950) docs: add Reward Gateway to README (#8950) (@mistercrunch) +- [#8576](https://github.com/apache/incubator-superset/pull/8576) [db migration] change datasources-clusters foreign key to cluster_id (#8576) (@serenajiang) +- [#8781](https://github.com/apache/incubator-superset/pull/8781) [css] Bringing Bootswatch in line with external variables, and other CSS tweaks (#8781) (@rusackas) +- [#8948](https://github.com/apache/incubator-superset/pull/8948) [fix] Enforce the QueryResult.df to be a pandas.DataFrame (Phase II) (#8948) (@john-bodley) +- [#8946](https://github.com/apache/incubator-superset/pull/8946) Ensure proper JSON serialization of numpy.ndarray (#8946) (@robdiciuccio) +- [#8945](https://github.com/apache/incubator-superset/pull/8945) [app] Fix, manage menu should be before charts (#8945) (@dpgaspar) +- [#8939](https://github.com/apache/incubator-superset/pull/8939) Add support for Dremio as a new source (#8939) (@narendrans) +- [#8914](https://github.com/apache/incubator-superset/pull/8914) [dashboard] Deprecate superset published API (#8914) (@dpgaspar) +- [#8942](https://github.com/apache/incubator-superset/pull/8942) [dashboards] Fix, missing mulexport permission (#8942) (@dpgaspar) +- [#8935](https://github.com/apache/incubator-superset/pull/8935) [fix] Enforce the query result to contain a data-frame (#8935) (@john-bodley) +- [#8912](https://github.com/apache/incubator-superset/pull/8912) Moving appbuilder.xxx out of view files and into app.py (#8912) (@craig-rueda) +- [#8931](https://github.com/apache/incubator-superset/pull/8931) Fix docstrings in superset/config.py (#8931) (@moshthepitt) +- [#8598](https://github.com/apache/incubator-superset/pull/8598) Revert "Make select_star work with SQL Lab views (#8598)" (#8930) (@graceguo-supercat) + ### 0.35.2 (2020/01/03 16:42 +00:00) - [#8918](https://github.com/apache/incubator-superset/pull/8918) [database] [log] Fix, Limit the amount of info on response (#8918) (@dpgaspar) - [#8759](https://github.com/apache/incubator-superset/pull/8759) Bump viz plugins for bug bash (#8759) (@etr2460) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 648847ec5518..d11d72df4cf9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,7 +75,7 @@ little bit helps, and credit will always be given. - [Creating a new language dictionary](#creating-a-new-language-dictionary) - [Tips](#tips) - [Adding a new datasource](#adding-a-new-datasource) - - [Creating a new visualization type](#creating-a-new-visualization-type) + - [Improving visualizations](#improving-visualizations) - [Adding a DB migration](#adding-a-db-migration) - [Merging DB migrations](#merging-db-migrations) - [SQL Lab Async](#sql-lab-async) @@ -389,7 +389,7 @@ Make sure your machine meets the [OS dependencies](https://superset.incubator.ap Developers should use a virtualenv. -``` +```bash pip install virtualenv ``` @@ -726,7 +726,7 @@ In TypeScript/JavaScript, the technique is similar: we import `t` (simple translation), `tn` (translation containing a number). ```javascript -import { t, tn } from "@superset-ui/translation"; +import { t, tn } from '@superset-ui/translation'; ``` ### Enabling language selection @@ -803,11 +803,31 @@ Then, [extract strings for the new language](#extracting-new-strings-for-transla This means it'll register MyDatasource and MyOtherDatasource in superset.my_models module in the source registry. -### Creating a new visualization type +### Improving visualizations + +Superset is working towards a plugin system where new visualizations can be installed as optional npm packages. To achieve this goal, we are not accepting pull requests for new community-contributed visualization types at the moment. However, bugfixes for current visualizations are welcome. To edit the frontend code for visualizations, you will have to check out a copy of [apache-superset/superset-ui-plugins](https://github.com/apache-superset/superset-ui-plugins): + +```bash +git clone https://github.com/apache-superset/superset-ui-plugins.git +yarn && yarn build +``` + +Then use `npm link` to create a symlink of the source code in `superset-frontend/node_modules`: + +```bash +cd incubator-superset/superset-frontend +npm link ../../superset-ui-plugins/packages/superset-ui-[PLUGIN NAME] + +# Or to link all plugin packages: +# npm link ../../superset-ui-plugins/packages/* + +# Start developing +npm run dev-server +``` + +When plugin packages are linked with `npm link`, the dev server will automatically load files from the plugin's `/src` directory. -Here's an example as a Github PR with comments that describe what the -different sections of the code do: -https://github.com/apache/incubator-superset/pull/3013 +Note that every time you do `npm install`, you will lose the symlink(s) and may have to run `npm link` again. ### Adding a DB migration @@ -905,12 +925,14 @@ To do this, you'll need to: - Configure a results backend, here's a local `FileSystemCache` example, not recommended for production, but perfect for testing (stores cache in `/tmp`) + ```python from werkzeug.contrib.cache import FileSystemCache RESULTS_BACKEND = FileSystemCache('/tmp/sqllab') ``` -* Start up a celery worker +- Start up a celery worker + ```shell script celery worker --app=superset.tasks.celery_app:app -Ofair ``` @@ -948,7 +970,6 @@ Note not all fields are correctly catagorized. The fields vary based on visualiz | Field | Type | Notes | | ---------------------- | --------------- | ------------------------------------- | -| `date_filter` | _N/A_ | _Deprecated?_ | | `date_time_format` | _N/A_ | _Deprecated?_ | | `druid_time_origin` | _string_ | The Druid **Origin** widget | | `granularity` | _string_ | The Druid **Time Granularity** widget | @@ -961,10 +982,8 @@ Note not all fields are correctly catagorized. The fields vary based on visualiz | Field | Type | Notes | | ------------------------- | --------------- | --------------------------- | -| `include_time` | _boolean_ | The **Include Time** widget | | `metrics` | _array(string)_ | See Query section | | `order_asc` | - | See Query section | -| `percent_metrics` | - | See Query section | | `row_limit` | - | See Query section | | `timeseries_limit_metric` | - | See Query section | @@ -988,7 +1007,6 @@ Note not all fields are correctly catagorized. The fields vary based on visualiz | Field | Type | Notes | | ----------------- | -------- | --------------------------------------------------- | | `metric_2` | - | The **Right Axis Metric** widget. See Query section | -| `y_axis_2_format` | _string_ | The **Right Axis Format** widget | ### Query @@ -1000,7 +1018,6 @@ Note not all fields are correctly catagorized. The fields vary based on visualiz | `contribution` | _boolean_ | The **Contribution** widget | | `groupby` | _array(string)_ | The **Group by** or **Series** widget | | `limit` | _number_ | The **Series Limit** widget | -| `max_bubble_size` | _number_ | The **Max Bubble Size** widget | | `metric`
`metric_2`
`metrics`
`percent_mertics`
`secondary_metric`
`size`
`x`
`y` | _string_,_object_,_array(string)_,_array(object)_ | The metric(s) depending on the visualization type | | `order_asc` | _boolean_ | The **Sort Descending** widget | | `row_limit` | _number_ | The **Row limit** widget | @@ -1016,62 +1033,24 @@ The `metric` (or equivalent) and `timeseries_limit_metric` fields are all compos The filter-box configuration references column names (via the `column` key) and optionally metric names (via the `metric` key) if sorting is defined. -### Options - -| Field | Type | Notes | -| ---------------------- | --------- | ------------------------------------ | -| `compare_lag` | _number_ | The **Comparison Period Lag** widget | -| `compare_suffix` | _string_ | The **Comparison suffix** widget | -| `show_trend_line` | _boolean_ | The **Show Trend Line** widget | -| `start_y_axis_at_zero` | _boolean_ | The **Start y-axis at 0** widget | - ### Chart Options | Field | Type | Notes | | --------------------- | --------- | ------------------------------------------------ | | `color_picker` | _object_ | The **Fixed Color** widget | -| `donut` | _boolean_ | The **Donut** widget | | `global_opacity` | _number_ | The **Opacity** widget | -| `header_font_size` | _number_ | The **Big Number Font Size** widget (or similar) | | `label_colors` | _object_ | The **Color Scheme** widget | -| `labels_outside` | _boolean_ | The **Put labels outside** widget | -| `line_interpolation` | _string_ | The **Line Style** widget | | `link_length` | _number_ | The **No of Bins** widget | | `normalized` | _boolean_ | The **Normalized** widget | | `number_format` | _string_ | The **Number format** widget | -| `pie_label_type` | _string_ | [HIDDEN] | -| `rich_tooltip` | _boolean_ | The **Rich Tooltip** widget | -| `send_time_range` | _boolean_ | The **Show Markers** widget | -| `show_brush` | _string_ | The **Show Range Filter** widget | -| `show_legend` | _boolean_ | The **Legend** widget | -| `show_markers` | _string_ | The **Show Markers** widget | -| `subheader_font_size` | _number_ | The **Subheader Font Size** widget | - -### X Axis - -| Field | Type | Notes | -| -------------------- | --------- | ---------------------------- | -| `bottom_margin` | _string_ | The **Bottom Margin** widget | -| `x_axis_format` | _string_ | The **X Axis Format** widget | -| `x_axis_label` | _string_ | The **X Axis Label** widget | -| `x_axis_showminmax` | _boolean_ | The **X bounds** widget | -| `x_axis_time_format` | _N/A_ | _Deprecated?_ | -| `x_log_scale` | _N/A_ | _Deprecated?_ | -| `x_ticks_layout` | _string_ | The **X Tick Layout** widget | ### Y Axis | Field | Type | Notes | | ------------------- | --------------- | ---------------------------- | -| `left_margin` | _number_ | The **Left Margin** widget | | `y_axis_2_label` | _N/A_ | _Deprecated?_ | -| `y_axis_bounds` | _array(string)_ | The **Y Axis Bounds** widget | | `y_axis_format` | _string_ | The **Y Axis Format** widget | -| `y_axis_label` | _string_ | The **Y Axis Label** widget | -| `y_axis_showminmax` | _boolean_ | The **Y bounds** widget | | `y_axis_zero` | _N/A_ | _Deprecated?_ | -| `y_log_scale` | _boolean_ | The **Y Log Scale** widget | -| `yscale_interval` | _N/A_ | _Deprecated?_ | Note the `y_axis_format` is defined under various section for some charts. @@ -1088,42 +1067,21 @@ Note the `y_axis_format` is defined under various section for some charts. | Field | Type | Notes | | ------------------------------- | ----- | ----- | | `add_to_dash` | _N/A_ | | -| `align_pn` | _N/A_ | | | `all_columns_y` | _N/A_ | | | `annotation_layers` | _N/A_ | | -| `autozoom` | _N/A_ | | -| `bar_stacked` | _N/A_ | | | `cache_timeout` | _N/A_ | | -| `canvas_image_rendering` | _N/A_ | | -| `cell_padding` | _N/A_ | | -| `cell_radius` | _N/A_ | | -| `cell_size` | _N/A_ | | -| `charge` | _N/A_ | | -| `clustering_radius` | _N/A_ | | | `code` | _N/A_ | | | `collapsed_fieldsets` | _N/A_ | | -| `color_pn` | _N/A_ | | | `column_collection` | _N/A_ | | -| `combine_metric` | _N/A_ | | | `comparison type` | _N/A_ | | | `contribution` | _N/A_ | | | `country_fieldtype` | _N/A_ | | -| `date_filter` | _N/A_ | | -| `deck_slices` | _N/A_ | | | `default_filters` | _N/A_ | | -| `dimension` | _N/A_ | | -| `domain_granularity` | _N/A_ | | -| `end_spatial` | _N/A_ | | | `entity` | _N/A_ | | -| `equal_date_size` | _N/A_ | | | `expanded_slices` | _N/A_ | | | `extra_filters` | _N/A_ | | -| `extruded` | _N/A_ | | -| `fill_color_picker` | _N/A_ | | -| `filled` | _N/A_ | | | `filter_immune_slice_fields` | _N/A_ | | | `filter_immune_slices` | _N/A_ | | -| `filter_nulls` | _N/A_ | | | `flt_col_0` | _N/A_ | | | `flt_col_1` | _N/A_ | | | `flt_eq_0` | _N/A_ | | @@ -1131,117 +1089,39 @@ Note the `y_axis_format` is defined under various section for some charts. | `flt_op_0` | _N/A_ | | | `flt_op_1` | _N/A_ | | | `goto_dash` | _N/A_ | | -| `grid_size` | _N/A_ | | -| `horizon_color_scale` | _N/A_ | | | `import_time` | _N/A_ | | -| `include_search` | _N/A_ | | -| `include_series` | _N/A_ | | -| `instant_filtering` | _N/A_ | | -| `js_agg_function` | _N/A_ | | -| `js_columns` | _N/A_ | | | `label` | _N/A_ | | -| `labels_outside` | _N/A_ | | -| `legend_position` | _N/A_ | | -| `line_charts` | _N/A_ | | -| `line_charts_2` | _N/A_ | | -| `line_column` | _N/A_ | | -| `line_type` | _N/A_ | | -| `line_width` | _N/A_ | | | `linear_color_scheme` | _N/A_ | | | `log_scale` | _N/A_ | | -| `mapbox_color` | _N/A_ | | | `mapbox_label` | _N/A_ | | | `mapbox_style` | _N/A_ | | -| `marker_labels` | _N/A_ | | -| `marker_line_labels` | _N/A_ | | -| `marker_lines` | _N/A_ | | -| `markers` | _N/A_ | | | `markup_type` | _N/A_ | | -| `max_radius` | _N/A_ | | -| `min_leaf_node_event_count` | _N/A_ | | | `min_periods` | _N/A_ | | -| `min_radius` | _N/A_ | | -| `multiplier` | _N/A_ | | | `new_dashboard_name` | _N/A_ | | | `new_slice_name` | _N/A_ | | | `normalize_across` | _N/A_ | | -| `num_buckets` | _N/A_ | | | `num_period_compare` | _N/A_ | | -| `order_bars` | _N/A_ | | -| `order_by_entity` | _N/A_ | | | `order_desc` | _N/A_ | | -| `page_length` | _N/A_ | | | `pandas_aggfunc` | _N/A_ | | -| `partition_limit` | _N/A_ | | -| `partition_threshold` | _N/A_ | | | `period_ratio_type` | _N/A_ | | | `perm` | _N/A_ | | -| `pivot_margins` | _N/A_ | | -| `point_radius` | _N/A_ | | -| `point_radius_fixed` | _N/A_ | | -| `point_radius_unit` | _N/A_ | | -| `point_unit` | _N/A_ | | -| `prefix_metric_with_slice_name` | _N/A_ | | -| `range_labels` | _N/A_ | | -| `ranges` | _N/A_ | | | `rdo_save` | _N/A_ | | -| `reduce_x_ticks` | _N/A_ | | | `refresh_frequency` | _N/A_ | | | `remote_id` | _N/A_ | | -| `render_while_dragging` | _N/A_ | | | `resample_fillmethod` | _N/A_ | | | `resample_how` | _N/A_ | | -| `resample_method` | _N/A_ | | -| `resample_rule` | _N/A_ | | -| `reverse_long_lat` | _N/A_ | | | `rolling_periods` | _N/A_ | | | `rolling_type` | _N/A_ | | | `rose_area_proportion` | _N/A_ | | -| `rotation` | _N/A_ | | | `save_to_dashboard_id` | _N/A_ | | | `schema` | _N/A_ | | | `select_country` | _N/A_ | | | `series` | _N/A_ | | -| `series_height` | _N/A_ | | -| `show_bar_value` | _N/A_ | | -| `show_brush` | _N/A_ | | | `show_bubbles` | _N/A_ | | -| `show_controls` | _N/A_ | | -| `show_datatable` | _N/A_ | | -| `show_druid_time_granularity` | _N/A_ | | -| `show_druid_time_origin` | _N/A_ | | -| `show_labels` | _N/A_ | | -| `show_metric_name` | _N/A_ | | -| `show_perc` | _N/A_ | | -| `show_sqla_time_column` | _N/A_ | | -| `show_sqla_time_granularity` | _N/A_ | | | `show_values` | _N/A_ | | -| `size_from` | _N/A_ | | -| `size_to` | _N/A_ | | | `slice_name` | _N/A_ | | -| `sort_x_axis` | _N/A_ | | -| `sort_y_axis` | _N/A_ | | -| `spatial` | _N/A_ | | -| `stacked_style` | _N/A_ | | -| `start_spatial` | _N/A_ | | -| `steps` | _N/A_ | | -| `stroke_color_picker` | _N/A_ | | -| `stroke_width` | _N/A_ | | -| `stroked` | _N/A_ | | -| `subdomain_granularity` | _N/A_ | | -| `subheader` | _N/A_ | | | `table_filter` | _N/A_ | | -| `table_timestamp_format` | _N/A_ | | -| `time_compare` | _N/A_ | | -| `time_series_option` | _N/A_ | | | `timed_refresh_immune_slices` | _N/A_ | | -| `toggle_polygons` | _N/A_ | | -| `transpose_pivot` | _N/A_ | | -| `treemap_ratio` | _N/A_ | | | `url` | _N/A_ | | | `userid` | _N/A_ | | -| `viewport` | _N/A_ | | -| `viewport_latitude` | _N/A_ | | -| `viewport_longitude` | _N/A_ | | | `viewport_zoom` | _N/A_ | | -| `whisker_options` | _N/A_ | | diff --git a/Dockerfile b/Dockerfile index 9f14b07aaaa6..a10fcfe26782 100644 --- a/Dockerfile +++ b/Dockerfile @@ -114,9 +114,10 @@ ENTRYPOINT ["/usr/bin/docker-entrypoint.sh"] ###################################################################### FROM lean AS dev -COPY ./requirements-dev.txt ./docker/requirements-extra.txt /app/ +COPY ./requirements-dev.txt ./docker/requirements* /app/ USER root RUN cd /app \ - && pip install --no-cache -r requirements-dev.txt -r requirements-extra.txt + && pip install --no-cache -r requirements-dev.txt -r requirements-extra.txt \ + && pip install --no-cache -r requirements-local.txt || true USER superset diff --git a/MANIFEST.in b/MANIFEST.in index 449f72ef37dd..4d7a98b2a7ac 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -18,6 +18,7 @@ include NOTICE include LICENSE.txt graft licenses/ include README.md +include superset-frontend/package.json recursive-include superset/examples * recursive-include superset/migrations * recursive-include superset/templates * diff --git a/README.md b/README.md index 09b780c60afb..141bddf85afb 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Installation & Configuration Resources ------------- * [Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org) -* [Docker image](https://hub.docker.com/r/amancevice/superset/) (community contributed) +* [Docker image](https://hub.docker.com/r/preset/superset/) * [Slides from Strata (March 2016)](https://drive.google.com/open?id=0B5PVE0gzO81oOVJkdF9aNkJMSmM) * [Stackoverflow tag](https://stackoverflow.com/questions/tagged/apache-superset) * [Join our Slack](https://join.slack.com/t/apache-superset/shared_invite/enQtNDMxMDY5NjM4MDU0LWJmOTcxYjlhZTRhYmEyYTMzOWYxOWEwMjcwZDZiNWRiNDY2NDUwNzcwMDFhNzE1ZmMxZTZlZWY0ZTQ2MzMyNTU) @@ -143,6 +143,7 @@ the world know they are using Superset. Join our growing community! 1. [Digit Game Studios](https://www.digitgaming.com/) 1. [Douban](https://www.douban.com/) 1. [Dragonpass](https://www.dragonpass.com.cn/) + 1. [Dremio](https://dremio.com) 1. [Endress+Hauser](http://www.endress.com/) 1. [Faasos](http://faasos.com/) 1. [Fanatics](https://www.fanatics.com) diff --git a/RELEASING/README.md b/RELEASING/README.md index 5669db98004e..6b69b8150b6a 100644 --- a/RELEASING/README.md +++ b/RELEASING/README.md @@ -65,26 +65,27 @@ the wrong files/using wrong names. There's a script to help you set correctly al necessary environment variables. Change your current directory to `superset/RELEASING` ```bash - # usage: . set_release_env.sh - # example: . set_release_env.sh 0.35.2rc1 myid@apache.org + # usage (BASH): . set_release_env.sh + # usage (ZSH): source set_release_env.sh + # + # example: source set_relese_env.sh 0.36.0rc3 myid@apache.org ``` -The script will output the exported variables. Here's example for 0.35.2rc2: +The script will output the exported variables. Here's example for 0.36.0rc3: ``` ------------------------------- Set Release env variables + SUPERSET_VERSION=0.36.0 + SUPERSET_RC=3 + SUPERSET_GITHUB_BRANCH=0.36 SUPERSET_PGP_FULLNAME=myid@apache.org - SUPERSET_VERSION_RC=0.35.2rc1 - SUPERSET_GITHUB_BRANCH=0.35 - SUPERSET_TMP_ASF_SITE_PATH=/tmp/incubator-superset-site-0.35.2 - SUPERSET_RELEASE_RC=apache-superset-incubating-0.35.2rc1 - SUPERSET_RELEASE_RC_TARBALL=apache-superset-incubating-0.35.2rc1-source.tar.gz - SUPERSET_RC=1 - SUPERSET_CONFIG_PATH=/Users/ville/superset/superset_config.py - SUPERSET_RELEASE=apache-superset-incubating-0.35.2 - SUPERSET_RELEASE_TARBALL=apache-superset-incubating-0.35.2-source.tar.gz - SUPERSET_VERSION=0.35.2 + SUPERSET_VERSION_RC=0.36.0rc3 + SUPERSET_RELEASE=apache-superset-incubating-0.36.0 + SUPERSET_RELEASE_RC=apache-superset-incubating-0.36.0rc3 + SUPERSET_RELEASE_TARBALL=apache-superset-incubating-0.36.0-source.tar.gz + SUPERSET_RELEASE_RC_TARBALL=apache-superset-incubating-0.36.0rc3-source.tar.gz + SUPERSET_TMP_ASF_SITE_PATH=/tmp/incubator-superset-site-0.36.0 ------------------------------- ``` @@ -114,10 +115,19 @@ section for the new release. Finally bump the version number on `superset-frontend/package.json` (replace with whichever version is being released excluding the RC version): ```json - "version": "0.35.2" + "version": "0.36.0" ``` -Commit the change with the version number, then git tag the version with the release candidate and push to the branch +Commit the change with the version number, then git tag the version with the release candidate and push to the branch: + +``` + # add changed files and commit + git add ... + git commit ... + # push new tag + git tag ${SUPERSET_VERSION_RC} + git push upstream ${SUPERSET_VERSION_RC} +``` ## Preparing the release candidate @@ -282,9 +292,9 @@ with the changes on `CHANGELOG.md` and `UPDATING.md`. ### Publishing a Convenience Release to PyPI -From the root of the repo running ./pypi_push.sh will build the -Javascript bundle and echo the twine command allowing you to publish -to PyPI. You may need to ask a fellow committer to grant +Using the final release tarball, unpack it and run `./pypi_push.sh`. +This script will build the Javascript bundle and echo the twine command +allowing you to publish to PyPI. You may need to ask a fellow committer to grant you access to it if you don't have access already. Make sure to create an account first if you don't have one, and reference your username while requesting access to push packages. diff --git a/RELEASING/set_release_env.sh b/RELEASING/set_release_env.sh index dca862dd64c4..6b408a8f1707 100755 --- a/RELEASING/set_release_env.sh +++ b/RELEASING/set_release_env.sh @@ -16,18 +16,31 @@ # limitations under the License. # usage() { - echo "usage: . set_release_env.sh " - echo "example: . set_relese_env.sh 0.35.2rc1 myid@apache.org" + echo "usage (BASH): . set_release_env.sh " + echo "usage (ZSH): source set_release_env.sh " + echo + echo "example: source set_relese_env.sh 0.36.0rc3 myid@apache.org" } if [ -z "$1" ] || [ -z "$2" ]; then usage; else if [[ ${1} =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)rc([0-9]+)$ ]]; then - VERSION_MAJOR="${BASH_REMATCH[1]}" - VERSION_MINOR="${BASH_REMATCH[2]}" - VERSION_PATCH="${BASH_REMATCH[3]}" - VERSION_RC="${BASH_REMATCH[4]}" + if [ -n "$ZSH_VERSION" ]; then + VERSION_MAJOR="${match[1]}" + VERSION_MINOR="${match[2]}" + VERSION_PATCH="${match[3]}" + VERSION_RC="${match[4]}" + elif [ -n "$BASH_VERSION" ]; then + VERSION_MAJOR="${BASH_REMATCH[1]}" + VERSION_MINOR="${BASH_REMATCH[2]}" + VERSION_PATCH="${BASH_REMATCH[3]}" + VERSION_RC="${BASH_REMATCH[4]}" + else + echo "Unsupported shell type, only zsh and bash supported" + exit 1 + fi + else echo "unable to parse version string ${1}. Example of valid version string: 0.35.2rc1" exit 1 diff --git a/cypress.json b/cypress.json deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/docker-compose.yml b/docker-compose.yml index 9f2e2d1e4d05..cb45e9c2b433 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,12 +29,14 @@ x-superset-volumes: &superset-volumes - ./docker/pythonpath_dev:/app/pythonpath - ./superset:/app/superset - ./superset-frontend:/app/superset-frontend + - node_modules:/app/superset-frontend/node_modules - superset_home:/app/superset_home version: "3.7" services: redis: image: redis:3.2 + container_name: superset_cache restart: unless-stopped ports: - "127.0.0.1:6379:6379" @@ -44,6 +46,7 @@ services: db: env_file: docker/.env image: postgres:10 + container_name: superset_db restart: unless-stopped ports: - "127.0.0.1:5432:5432" @@ -51,9 +54,10 @@ services: - db_home:/var/lib/postgresql/data superset: + env_file: docker/.env build: *superset-build + container_name: superset_app command: ["flask", "run", "-p", "8088", "--with-threads", "--reload", "--debugger", "--host=0.0.0.0"] - env_file: docker/.env restart: unless-stopped ports: - 8088:8088 @@ -62,6 +66,7 @@ services: superset-init: build: *superset-build + container_name: superset_init command: ["/app/docker-init.sh"] env_file: docker/.env depends_on: *superset-depends-on @@ -69,13 +74,15 @@ services: superset-node: image: node:10-jessie - command: ["bash", "-c", "cd /app/superset-frontend && npm install && npm run dev"] + container_name: superset_node + command: ["bash", "-c", "cd /app/superset-frontend && npm install --global webpack webpack-cli && npm install && npm run dev"] env_file: docker/.env depends_on: *superset-depends-on volumes: *superset-volumes superset-worker: build: *superset-build + container_name: superset_worker command: ["celery", "worker", "--app=superset.tasks.celery_app:app", "-Ofair"] env_file: docker/.env restart: unless-stopped @@ -85,6 +92,8 @@ services: volumes: superset_home: external: false + node_modules: + external: false db_home: external: false redis: diff --git a/docker/README.md b/docker/README.md index 62a97aa24246..caed1a7b0356 100644 --- a/docker/README.md +++ b/docker/README.md @@ -37,6 +37,18 @@ intended for use with local development. In order to override configuration settings locally, simply make a copy of [./docker/pythonpath/superset_config_local.example](./docker/pythonpath/superset_config_local.example) into [./docker/pythonpath/superset_config_docker.py](./docker/pythonpath/superset_config_docker.py) (git ignored) and fill in your overrides. +### Local packages + +If you want to add python packages in order to test things like DBs locally, you can simply add a local requirements.txt (./docker/requirements-local.txt) +and rebuild your docker stack. + +Steps: + 1. Create ./docker/requirements-local.txt + 2. Add your new packages + 3. Rebuild docker-compose + a. `docker-compose down -v` + b. `docker-compose up` + ## Initializing Database The DB will initialize itself upon startup via the init container (superset-init) diff --git a/docs/conf.py b/docs/conf.py index 89042b23e45d..d1aec85ee160 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -63,7 +63,7 @@ # General information about the project. project = "Apache Superset" -copyright = "Copyright © 2019 The Apache Software Foundation, Licensed under the Apache License, Version 2.0." +copyright = "Copyright © 2020 The Apache Software Foundation, Licensed under the Apache License, Version 2.0." author = u"Apache Superset Dev" # The version info for the project you're documenting, acts as replacement for diff --git a/docs/index.rst b/docs/index.rst index 049885f0d461..78fa3decb2f3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -112,7 +112,7 @@ The following RDBMS are currently supported: - `ClickHouse `_ - `CockroachDB `_ - `Dremio `_ -- `Elasticsearch `_ +- `Elasticsearch `_ - `Exasol `_ - `Google Sheets `_ - `Greenplum `_ diff --git a/docs/installation.rst b/docs/installation.rst index c4d3ec36a10a..baa14a67f519 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -377,7 +377,7 @@ Here's a list of some of the recommended packages. +------------------+---------------------------------------+-------------------------------------------------+ | CockroachDB | ``pip install cockroachdb`` | ``cockroachdb://`` | +------------------+---------------------------------------+-------------------------------------------------+ -| Dremio | ``pip install sqlalchemy_dremio`` | ``dremio://user:pwd@host:31010/`` | +| Dremio | ``pip install sqlalchemy_dremio`` | ``dremio://`` | +------------------+---------------------------------------+-------------------------------------------------+ | Elasticsearch | ``pip install elasticsearch-dbapi`` | ``elasticsearch+http://`` | +------------------+---------------------------------------+-------------------------------------------------+ @@ -647,6 +647,74 @@ section in `config.py`: This will cache all the charts in the top 5 most popular dashboards every hour. For other strategies, check the `superset/tasks/cache.py` file. +Caching Thumbnails +------------------ + +This is an optional feature that can be turned on by activating it's feature flag on config: + +.. code-block:: python + + FEATURE_FLAGS = { + "THUMBNAILS": True, + "THUMBNAILS_SQLA_LISTENERS": True, + } + + +For this feature you will need a cache system and celery workers. All thumbnails are store on cache and are processed +asynchronously by the workers. + +An example config where images are stored on S3 could be: + +.. code-block:: python + + from flask import Flask + from s3cache.s3cache import S3Cache + + ... + + class CeleryConfig(object): + BROKER_URL = "redis://localhost:6379/0" + CELERY_IMPORTS = ("superset.sql_lab", "superset.tasks", "superset.tasks.thumbnails") + CELERY_RESULT_BACKEND = "redis://localhost:6379/0" + CELERYD_PREFETCH_MULTIPLIER = 10 + CELERY_ACKS_LATE = True + + + CELERY_CONFIG = CeleryConfig + + def init_thumbnail_cache(app: Flask) -> S3Cache: + return S3Cache("bucket_name", 'thumbs_cache/') + + + THUMBNAIL_CACHE_CONFIG = init_thumbnail_cache + # Async selenium thumbnail task will use the following user + THUMBNAIL_SELENIUM_USER = "Admin" + +Using the above example cache keys for dashboards will be `superset_thumb__dashboard__{ID}` + +You can override the base URL for selenium using: + +.. code-block:: python + + WEBDRIVER_BASEURL = "https://superset.company.com" + + +Additional selenium web drive config can be set using `WEBDRIVER_CONFIGURATION` + +You can implement a custom function to authenticate selenium, the default uses flask-login session cookie. +An example of a custom function signature: + +.. code-block:: python + + def auth_driver(driver: WebDriver, user: "User") -> WebDriver: + pass + + +Then on config: + +.. code-block:: python + + WEBDRIVER_AUTH_FUNC = auth_driver Deeper SQLAlchemy integration ----------------------------- @@ -729,12 +797,20 @@ The native Druid connector (behind the ``DRUID_IS_ACTIVE`` feature flag) is slowly getting deprecated in favor of the SQLAlchemy/DBAPI connector made available in the ``pydruid`` library. +To use a custom SSL certificate to validate HTTPS requests, the certificate +contents can be entered in the ``Root Certificate`` field in the Database +dialog. When using a custom certificate, ``pydruid`` will automatically use +``https`` scheme. To disable SSL verification add the following to extras: +``engine_params": {"connect_args": {"scheme": "https", "ssl_verify_cert": false}}`` + Dremio ------ Install the following dependencies to connect to Dremio: * Dremio SQLAlchemy: ``pip install sqlalchemy_dremio`` + + * If you receive any errors during the installation of ``sqlalchemy_dremio``, make sure to install the prerequisites for PyODBC properly by following the instructions for your OS here: https://github.com/narendrans/sqlalchemy_dremio#installation * Dremio's ODBC driver: https://www.dremio.com/drivers/ Example SQLAlchemy URI: ``dremio://dremio:dremio123@localhost:31010/dremio`` @@ -1079,6 +1155,59 @@ in this dictionary are made available for users to use in their SQL. 'my_crazy_macro': lambda x: x*2, } +Besides default Jinja templating, SQL lab also supports self-defined template +processor by setting the ``CUSTOM_TEMPLATE_PROCESSORS`` in your superset configuration. +The values in this dictionary overwrite the default Jinja template processors of the +specified database engine. +The example below configures a custom presto template processor which implements +its own logic of processing macro template with regex parsing. It uses ``$`` style +macro instead of ``{{ }}`` style in Jinja templating. By configuring it with +``CUSTOM_TEMPLATE_PROCESSORS``, sql template on presto database is processed +by the custom one rather than the default one. + +.. code-block:: python + + def DATE( + ts: datetime, day_offset: SupportsInt = 0, hour_offset: SupportsInt = 0 + ) -> str: + """Current day as a string.""" + day_offset, hour_offset = int(day_offset), int(hour_offset) + offset_day = (ts + timedelta(days=day_offset, hours=hour_offset)).date() + return str(offset_day) + + class CustomPrestoTemplateProcessor(PrestoTemplateProcessor): + """A custom presto template processor.""" + + engine = "presto" + + def process_template(self, sql: str, **kwargs) -> str: + """Processes a sql template with $ style macro using regex.""" + # Add custom macros functions. + macros = { + "DATE": partial(DATE, datetime.utcnow()) + } # type: Dict[str, Any] + # Update with macros defined in context and kwargs. + macros.update(self.context) + macros.update(kwargs) + + def replacer(match): + """Expand $ style macros with corresponding function calls.""" + macro_name, args_str = match.groups() + args = [a.strip() for a in args_str.split(",")] + if args == [""]: + args = [] + f = macros[macro_name[1:]] + return f(*args) + + macro_names = ["$" + name for name in macros.keys()] + pattern = r"(%s)\s*\(([^()]*)\)" % "|".join(map(re.escape, macro_names)) + return re.sub(pattern, replacer, sql) + + CUSTOM_TEMPLATE_PROCESSORS = { + CustomPrestoTemplateProcessor.engine: CustomPrestoTemplateProcessor + } + + SQL Lab also includes a live query validation feature with pluggable backends. You can configure which validation implementation is used with which database engine by adding a block like the following to your config.py: diff --git a/docs/requirements.txt b/docs/requirements.txt index 113860b3dc97..f1b96ba607ad 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -14,6 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -sphinx==2.1.2 -sphinx_autodoc_typehints==1.6.0 +sphinx==3.0.1 +sphinx_autodoc_typehints==1.10.3 sphinx-rtd-theme==0.4.3 diff --git a/docs/sqllab.rst b/docs/sqllab.rst index 992a689f581d..aace28f119e9 100644 --- a/docs/sqllab.rst +++ b/docs/sqllab.rst @@ -104,6 +104,15 @@ environment using the configuration variable ``JINJA_CONTEXT_ADDONS``. All objects referenced in this dictionary will become available for users to integrate in their queries in **SQL Lab**. +Customize templating +'''''''''''''''''''' + +As mentioned in the `Installation & Configuration `__ documentation, +it's possible for administrators to overwrite Jinja templating with your customized +template processor using the configuration variable ``CUSTOM_TEMPLATE_PROCESSORS``. +The template processors referenced in the dictionary will overwrite default Jinja template processors +of the specified database engines. + Query cost estimation ''''''''''''''''''''' diff --git a/install/helm/superset/Chart.yaml b/install/helm/superset/Chart.yaml index 83bbd394d176..973f6c70479c 100644 --- a/install/helm/superset/Chart.yaml +++ b/install/helm/superset/Chart.yaml @@ -16,7 +16,7 @@ # apiVersion: v1 appVersion: "1.0" -description: A Helm chart for Kubernetes +description: Apache Superset is a modern, enterprise-ready business intelligence web application name: superset maintainers: - name: Chuan-Yen Chiang diff --git a/install/helm/superset/templates/configmap.yaml b/install/helm/superset/requirements.yaml similarity index 72% rename from install/helm/superset/templates/configmap.yaml rename to install/helm/superset/requirements.yaml index 7a4e5151d109..1f47d5a082b4 100644 --- a/install/helm/superset/templates/configmap.yaml +++ b/install/helm/superset/requirements.yaml @@ -14,14 +14,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # -apiVersion: v1 -kind: ConfigMap -metadata: - name: superset-configmap - labels: - app: {{ template "superset.name" . }} - chart: {{ template "superset.chart" . }} - release: {{ .Release.Name }} - heritage: {{ .Release.Service }} -data: -{{ (.Files.Glob "config/*").AsConfig | indent 2 }} +dependencies: +- name: postgresql + version: 8.1.4 + repository: https://kubernetes-charts.storage.googleapis.com/ + condition: postgresql.enabled +- name: redis + version: 10.3.4 + repository: https://kubernetes-charts.storage.googleapis.com/ + condition: redis.enabled \ No newline at end of file diff --git a/install/helm/superset/templates/_helpers.tpl b/install/helm/superset/templates/_helpers.tpl index 3c104330a32d..d5dd26f6af0d 100644 --- a/install/helm/superset/templates/_helpers.tpl +++ b/install/helm/superset/templates/_helpers.tpl @@ -48,3 +48,39 @@ Create chart name and version as used by the chart label. {{- define "superset.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} {{- end -}} + +{{- define "superset-connections.script" }} +import os +from werkzeug.contrib.cache import RedisCache +MAPBOX_API_KEY = os.getenv('MAPBOX_API_KEY', '') + +CACHE_CONFIG = { + 'CACHE_TYPE': 'redis', + 'CACHE_DEFAULT_TIMEOUT': 300, + 'CACHE_KEY_PREFIX': 'superset_', + 'CACHE_REDIS_HOST': os.getenv('REDIS_HOST'), + 'CACHE_REDIS_PORT': os.getenv('REDIS_PORT'), + 'CACHE_REDIS_DB': 1, + 'CACHE_REDIS_URL': 'redis://%s:%s/1' % (os.getenv('REDIS_HOST'),os.getenv('REDIS_PORT'))} +SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://%s:%s@%s:%s/%s' % (os.getenv('DB_USER'), os.getenv('DB_PASS'), os.getenv('DB_HOST'), os.getenv('DB_PORT'), os.getenv('DB_NAME')) +SQLALCHEMY_TRACK_MODIFICATIONS = True +SECRET_KEY = 'thisISaSECRET_1234' +# Flask-WTF flag for CSRF +WTF_CSRF_ENABLED = True +# Add endpoints that need to be exempt from CSRF protection +WTF_CSRF_EXEMPT_LIST = [] +# A CSRF token that expires in 1 year +WTF_CSRF_TIME_LIMIT = 60 * 60 * 24 * 365 +class CeleryConfig(object): + BROKER_URL = 'redis://%s:%s/0' % (os.getenv('REDIS_HOST'),os.getenv('REDIS_PORT')) + CELERY_IMPORTS = ('superset.sql_lab', ) + CELERY_RESULT_BACKEND = 'redis://%s:%s/0' % (os.getenv('REDIS_HOST'),os.getenv('REDIS_PORT')) + CELERY_ANNOTATIONS = {'tasks.add': {'rate_limit': '10/s'}} + +CELERY_CONFIG = CeleryConfig +RESULTS_BACKEND = RedisCache( + host= os.getenv('REDIS_HOST'), + port= os.getenv('REDIS_PORT'), + key_prefix='superset_results' +) +{{- end }} \ No newline at end of file diff --git a/install/helm/superset/templates/deployment.yaml b/install/helm/superset/templates/deployment.yaml index d56c59e28259..da2ee886759b 100644 --- a/install/helm/superset/templates/deployment.yaml +++ b/install/helm/superset/templates/deployment.yaml @@ -39,9 +39,46 @@ spec: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: REDIS_HOST + valueFrom: + secretKeyRef: + name: superset-secret + key: redis_host + - name: REDIS_PORT + valueFrom: + secretKeyRef: + name: superset-secret + key: redis_port + - name: DB_HOST + valueFrom: + secretKeyRef: + name: superset-secret + key: db_host + - name: DB_PORT + valueFrom: + secretKeyRef: + name: superset-secret + key: db_port + - name: DB_USER + valueFrom: + secretKeyRef: + name: superset-secret + key: db_user + - name: DB_PASS + valueFrom: + secretKeyRef: + name: superset-secret + key: db_pass + - name: DB_NAME + valueFrom: + secretKeyRef: + name: superset-secret + key: db_name volumeMounts: - name: superset-config - mountPath: /etc/superset/ + mountPath: "/etc/superset" + readOnly: true ports: - name: http containerPort: 8088 @@ -61,6 +98,6 @@ spec: {{ toYaml . | indent 8 }} {{- end }} volumes: - - name: "superset-config" - configMap: - name: superset-configmap + - name: superset-config + secret: + secretName: superset-config \ No newline at end of file diff --git a/install/helm/superset/templates/init-job.yaml b/install/helm/superset/templates/init-job.yaml new file mode 100644 index 000000000000..5b287a4218bc --- /dev/null +++ b/install/helm/superset/templates/init-job.yaml @@ -0,0 +1,80 @@ +# +# 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. +# +{{- if and ( .Values.initContainers ) ( .Values.init.enabled ) }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ template "superset.name" . }}-init-db + annotations: +spec: + template: + metadata: + name: {{ template "superset.name" . }}-init-db + spec: + initContainers: + {{- toYaml .Values.initContainers | nindent 6 }} + containers: + - name: {{ template "superset.name" . }}-init-db + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + env: + - name: REDIS_HOST + valueFrom: + secretKeyRef: + name: superset-secret + key: redis_host + - name: REDIS_PORT + valueFrom: + secretKeyRef: + name: superset-secret + key: redis_port + - name: DB_HOST + valueFrom: + secretKeyRef: + name: superset-secret + key: db_host + - name: DB_PORT + valueFrom: + secretKeyRef: + name: superset-secret + key: db_port + - name: DB_USER + valueFrom: + secretKeyRef: + name: superset-secret + key: db_user + - name: DB_PASS + valueFrom: + secretKeyRef: + name: superset-secret + key: db_pass + - name: DB_NAME + valueFrom: + secretKeyRef: + name: superset-secret + key: db_name + imagePullPolicy: {{ .Values.image.pullPolicy }} + volumeMounts: + - name: superset-config + mountPath: "/etc/superset" + readOnly: true + command: [ "/bin/sh", "-c", "{{ .Values.init.initscript }}" ] + volumes: + - name: superset-config + secret: + secretName: superset-config + restartPolicy: Never +{{- end }} \ No newline at end of file diff --git a/install/helm/superset/templates/secret-superset-config.yaml b/install/helm/superset/templates/secret-superset-config.yaml new file mode 100644 index 000000000000..2886cfad6d5a --- /dev/null +++ b/install/helm/superset/templates/secret-superset-config.yaml @@ -0,0 +1,28 @@ +# +# 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. +# +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "superset.fullname" . }}-config + labels: + app: {{ template "superset.fullname" . }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + superset_config.py: {{ include "superset-connections.script" . | b64enc }} \ No newline at end of file diff --git a/install/helm/superset/templates/secret.yaml b/install/helm/superset/templates/secret.yaml new file mode 100644 index 000000000000..6f326154aa35 --- /dev/null +++ b/install/helm/superset/templates/secret.yaml @@ -0,0 +1,34 @@ +# +# 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. +# +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "superset.fullname" . }}-secret + labels: + app: {{ template "superset.fullname" . }} + chart: {{ template "superset.chart" . }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + redis_host: {{ .Values.supersetNode.connections.redis_host | b64enc | quote }} + redis_port: {{ .Values.supersetNode.connections.redis_port | b64enc | quote }} + db_host: {{ .Values.supersetNode.connections.db_host | b64enc | quote }} + db_port: {{ .Values.supersetNode.connections.db_port | b64enc | quote }} + db_user: {{ .Values.supersetNode.connections.db_user | b64enc | quote }} + db_pass: {{ .Values.supersetNode.connections.db_pass | b64enc | quote }} + db_name: {{ .Values.supersetNode.connections.db_name | b64enc | quote }} \ No newline at end of file diff --git a/install/helm/superset/values.yaml b/install/helm/superset/values.yaml index 0fc3c76588db..4db713e4b159 100644 --- a/install/helm/superset/values.yaml +++ b/install/helm/superset/values.yaml @@ -26,6 +26,22 @@ image: tag: latest pullPolicy: IfNotPresent +initContainers: + - name: wait-for-postgres + image: busybox:latest + imagePullPolicy: IfNotPresent + env: + - name: DB_HOST + valueFrom: + secretKeyRef: + name: superset-secret + key: db_host + - name: DB_PORT + valueFrom: + secretKeyRef: + name: superset-secret + key: db_port + command: [ "/bin/sh", "-c", "until nc -zv $DB_HOST $DB_PORT -w1; do echo 'waiting for db'; sleep 1; done" ] service: type: NodePort port: 8088 @@ -54,9 +70,133 @@ resources: {} # requests: # cpu: 100m # memory: 128Mi +#Superset node configuration +supersetNode: + connections: + redis_host: superset-redis-headless + redis_port: "6379" + db_host: superset-postgresql + db_port: "5432" + db_user: superset + db_pass: superset + db_name: superset + +# ----------------------------------------------------------------------------- +# Miscellaneous parameters +# ----------------------------------------------------------------------------- + +init: + enabled: true + initscript: |- + superset db upgrade && \ + superset init && \ + superset fab create-admin \ + --username admin \ + --firstname Superset \ + --lastname Admin \ + --email admin@superset.com \ + --password admin || true +## +## Configuration values for the postgresql dependency. +## ref: https://github.com/kubernetes/charts/blob/master/stable/postgresql/README.md +postgresql: + ## + ## Use the PostgreSQL chart dependency. + ## Set to false if bringing your own PostgreSQL. + enabled: true + + ## + ## The name of an existing secret that contains the postgres password. + existingSecret: + + ## Name of the key containing the secret. + existingSecretKey: postgresql-password + + ## + ## If you are bringing your own PostgreSQL, you should set postgresHost and + ## also probably service.port, postgresqlUsername, postgresqlPassword, and postgresqlDatabase + ## postgresHost: + ## + ## PostgreSQL port + service: + port: 5432 + ## PostgreSQL User to create. + postgresqlUsername: superset + ## + ## PostgreSQL Password for the new user. + ## If not set, a random 10 characters password will be used. + postgresqlPassword: superset + ## + ## PostgreSQL Database to create. + postgresqlDatabase: superset + ## + ## Persistent Volume Storage configuration. + ## ref: https://kubernetes.io/docs/user-guide/persistent-volumes + persistence: + ## + ## Enable PostgreSQL persistence using Persistent Volume Claims. + enabled: true + ## + ## Persistant class + # storageClass: classname + ## + ## Access modes: + accessModes: + - ReadWriteOnce + +## Configuration values for the Redis dependency. +## ref: https://github.com/kubernetes/charts/blob/master/stable/redis/README.md +redis: + ## + ## Use the redis chart dependency. + ## Set to false if bringing your own redis. + enabled: true + + usePassword: false + + ## + ## The name of an existing secret that contains the redis password. + existingSecret: + + ## Name of the key containing the secret. + existingSecretKey: redis-password + + ## + ## If you are bringing your own redis, you can set the host in redisHost. + ## redisHost: + ## + ## Redis password + ## + password: superset + ## + ## Master configuration + master: + ## + ## Image configuration + # image: + ## + ## docker registry secret names (list) + # pullSecrets: nil + ## + ## Configure persistance + persistence: + ## + ## Use a PVC to persist data. + enabled: false + ## + ## Persistant class + # storageClass: classname + ## + ## Access mode: + accessModes: + - ReadWriteOnce + ## + ## Disable cluster management by default. + cluster: + enabled: false nodeSelector: {} tolerations: [] -affinity: {} +affinity: {} \ No newline at end of file diff --git a/pypi_push.sh b/pypi_push.sh index 065fa262a202..8b4db99065f1 100755 --- a/pypi_push.sh +++ b/pypi_push.sh @@ -21,6 +21,13 @@ git branch rm superset/static/assets/* cd superset-frontend/ npm ci && npm run build -cd ../.. +cd ../ +echo "----------------------" +echo "Compiling translations" +echo "----------------------" +flask fab babel-compile --target superset/translations +echo "----------------------" +echo "Creating distribution " +echo "----------------------" python setup.py sdist echo "RUN: twine upload dist/apache-superset-{SUPERSET_VERSION}.tar.gz" diff --git a/requirements-dev.txt b/requirements-dev.txt index e3c6f957cd68..a408b03ea036 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,13 +14,13 @@ # See the License for the specific language governing permissions and # limitations under the License. # -black==19.3b0 +black==19.10b0 coverage==4.5.3 flask-cors==3.0.7 flask-testing==0.7.1 ipdb==0.12 isort==4.3.21 -mypy==0.670 +mypy==0.770 nose==1.3.7 pip-tools==4.5.1 pre-commit==1.17.0 @@ -33,3 +33,4 @@ redis==3.2.1 requests==2.22.0 statsd==3.3.0 tox==3.11.1 +pillow==7.0.0 diff --git a/requirements.txt b/requirements.txt index 4babe3d7bf3d..214fc54ce6c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,10 +9,10 @@ amqp==2.5.2 # via kombu apispec[yaml]==1.3.3 # via flask-appbuilder attrs==19.3.0 # via jsonschema babel==2.8.0 # via flask-babel -backoff==1.10.0 +backoff==1.10.0 # via apache-superset (setup.py) billiard==3.6.1.0 # via celery -bleach==3.1.0 -celery==4.4.0 +bleach==3.1.0 # via apache-superset (setup.py) +celery==4.4.0 # via apache-superset (setup.py) cffi==1.13.2 # via cryptography click==7.1.1 # via apache-superset (setup.py), flask, flask-appbuilder colorama==0.4.3 # via apache-superset (setup.py), flask-appbuilder @@ -21,65 +21,68 @@ croniter==0.3.31 # via apache-superset (setup.py) cryptography==2.8 # via apache-superset (setup.py) decorator==4.4.1 # via retry defusedxml==0.6.0 # via python3-openid -flask-appbuilder==2.3.0 +flask-appbuilder==2.3.2 # via apache-superset (setup.py) flask-babel==1.0.0 # via flask-appbuilder -flask-caching==1.8.0 -flask-compress==1.4.0 +flask-caching==1.8.0 # via apache-superset (setup.py) +flask-compress==1.4.0 # via apache-superset (setup.py) flask-jwt-extended==3.24.1 # via flask-appbuilder flask-login==0.4.1 # via flask-appbuilder -flask-migrate==2.5.2 +flask-migrate==2.5.2 # via apache-superset (setup.py) flask-openid==1.2.5 # via flask-appbuilder flask-sqlalchemy==2.4.1 # via flask-appbuilder, flask-migrate -flask-talisman==0.7.0 -flask-wtf==0.14.2 -flask==1.1.1 +flask-talisman==0.7.0 # via apache-superset (setup.py) +flask-wtf==0.14.2 # via apache-superset (setup.py), flask-appbuilder +flask==1.1.1 # via apache-superset (setup.py), flask-appbuilder, flask-babel, flask-caching, flask-compress, flask-jwt-extended, flask-login, flask-migrate, flask-openid, flask-sqlalchemy, flask-wtf geographiclib==1.50 # via geopy -geopy==1.20.0 -gunicorn==20.0.4 -humanize==0.5.1 +geopy==1.20.0 # via apache-superset (setup.py) +gunicorn==20.0.4 # via apache-superset (setup.py) +humanize==0.5.1 # via apache-superset (setup.py) importlib-metadata==1.4.0 # via jsonschema, kombu -isodate==0.6.0 +isodate==0.6.0 # via apache-superset (setup.py) itsdangerous==1.1.0 # via flask jinja2==2.10.3 # via flask, flask-babel jsonschema==3.2.0 # via flask-appbuilder kombu==4.6.7 # via celery mako==1.1.1 # via alembic -markdown==3.1.1 +markdown==3.1.1 # via apache-superset (setup.py) markupsafe==1.1.1 # via jinja2, mako marshmallow-enum==1.5.1 # via flask-appbuilder marshmallow-sqlalchemy==0.21.0 # via flask-appbuilder marshmallow==2.19.5 # via flask-appbuilder, marshmallow-enum, marshmallow-sqlalchemy more-itertools==8.1.0 # via zipp -msgpack==0.6.2 +msgpack==0.6.2 # via apache-superset (setup.py) numpy==1.18.1 # via pandas, pyarrow -pandas==0.25.3 -parsedatetime==2.5 -pathlib2==2.3.5 -polyline==1.4.0 -prison==0.1.2 # via flask-appbuilder +pandas==1.0.3 # via apache-superset (setup.py) +parsedatetime==2.5 # via apache-superset (setup.py) +pathlib2==2.3.5 # via apache-superset (setup.py) +polyline==1.4.0 # via apache-superset (setup.py) +prison==0.1.3 # via flask-appbuilder py==1.8.1 # via retry -pyarrow==0.16.0 +pyarrow==0.16.0 # via apache-superset (setup.py) pycparser==2.19 # via cffi pyjwt==1.7.1 # via flask-appbuilder, flask-jwt-extended pyrsistent==0.15.7 # via jsonschema -python-dateutil==2.8.1 -python-dotenv==0.10.5 +python-dateutil==2.8.1 # via alembic, apache-superset (setup.py), croniter, flask-appbuilder, pandas +python-dotenv==0.10.5 # via apache-superset (setup.py) python-editor==1.0.4 # via alembic -python-geohash==0.8.5 +python-geohash==0.8.5 # via apache-superset (setup.py) python3-openid==3.1.0 # via flask-openid pytz==2019.3 # via babel, celery, flask-babel, pandas -pyyaml==5.3 -retry==0.9.2 -selenium==3.141.0 -simplejson==3.17.0 +pyyaml==5.3 # via apache-superset (setup.py), apispec +retry==0.9.2 # via apache-superset (setup.py) +selenium==3.141.0 # via apache-superset (setup.py) +simplejson==3.17.0 # via apache-superset (setup.py) six==1.14.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, isodate, jsonschema, pathlib2, polyline, prison, pyarrow, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json -sqlalchemy-utils==0.36.1 -sqlalchemy==1.3.12 -sqlparse==0.3.0 +sqlalchemy-utils==0.36.1 # via apache-superset (setup.py), flask-appbuilder +sqlalchemy==1.3.16 # via alembic, apache-superset (setup.py), flask-sqlalchemy, marshmallow-sqlalchemy, sqlalchemy-utils +sqlparse==0.3.0 # via apache-superset (setup.py) urllib3==1.25.8 # via selenium vine==1.3.0 # via amqp, celery webencodings==0.5.1 # via bleach werkzeug==0.16.0 # via flask, flask-jwt-extended -wtforms-json==0.3.3 +wtforms-json==0.3.3 # via apache-superset (setup.py) wtforms==2.2.1 # via flask-wtf, wtforms-json zipp==2.0.0 # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/scripts/fossa.sh b/scripts/fossa.sh index 7ba54ded40b7..5016d7cb3984 100755 --- a/scripts/fossa.sh +++ b/scripts/fossa.sh @@ -19,8 +19,9 @@ # This is the recommended way to install FOSSA's cli per the docs: # https://docs.fossa.com/docs/travisci#section-add-fossa-steps-to-travisyml -curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | sudo bash +curl -s -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | sudo bash # This key is a push-only API key, also recommended for public projects # https://docs.fossa.com/docs/api-reference#section-push-only-api-token -FOSSA_API_KEY="f72e93645bdfeab94bd227c7bbdda4ef" fossa +export FOSSA_API_KEY="${FOSSA_API_KEY:-f72e93645bdfeab94bd227c7bbdda4ef}" +fossa analyze diff --git a/scripts/python_tests.sh b/scripts/python_tests.sh new file mode 100755 index 000000000000..33768af3156a --- /dev/null +++ b/scripts/python_tests.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# +# 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. +# +set -e + +export SUPERSET_CONFIG=${SUPERSET_CONFIG:-tests.superset_test_config} +echo "Superset config module: $SUPERSET_CONFIG" + +superset db upgrade +superset init +nosetests --stop tests/load_examples_test.py +nosetests --stop --exclude=load_examples_test tests diff --git a/scripts/tests/README.md b/scripts/tests/README.md new file mode 100644 index 000000000000..814e54235fad --- /dev/null +++ b/scripts/tests/README.md @@ -0,0 +1,51 @@ + + +# Utility script to run tests faster + +By default tests will be run using the Postgres container defined at the `docker-compose` file on the root of the repo, +so prior to using this script make sure to launch the dev containers. + +You can use a different DB backend by defining `SUPERSET__SQLALCHEMY_DATABASE_URI` env var. + +## Use: + +From the superset repo root directory: + +- Example run a single test module: +```$bash +scripts/tests/run.sh tests.charts.api_tests +``` + +- Example run a single test: +```$bash +scripts/tests/run.sh tests.charts.api_tests:ChartApiTests.test_get_charts +``` + +- Example run a single test, without any init procedures. Init procedures include: + resetting test database, db upgrade, superset init, loading example data. If your tests + are idempotent, after the first run, subsequent runs are really fast +```$bash +scripts/tests/run.sh tests.charts.api_tests:ChartApiTests.test_get_charts --no-init +``` + +- Example for not recreating the test DB (will still run all the tests init procedures) +```$bash +scripts/tests/run.sh tests.charts.api_tests:ChartApiTests.test_get_charts --no-reset-db +``` diff --git a/scripts/tests/run.sh b/scripts/tests/run.sh new file mode 100755 index 000000000000..a4be3ec55625 --- /dev/null +++ b/scripts/tests/run.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash + +# 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. + +set -e + +# +# Reset test DATABASE +# +function reset_db() { + echo -------------------- + echo Reseting test DB + echo -------------------- + docker exec -i superset_db bash -c "/usr/bin/psql -h 127.0.0.1 -U ${DB_USER} -w -c 'DROP DATABASE ${DB_NAME};'" + docker exec -i superset_db bash -c "/usr/bin/psql -h 127.0.0.1 -U ${DB_USER} -w -c 'CREATE DATABASE ${DB_NAME};'" +} + +# +# Run init test procedures +# +function test_init() { + echo -------------------- + echo Upgrading + echo -------------------- + superset db upgrade + echo -------------------- + echo Superset init + echo -------------------- + superset init + echo -------------------- + echo Load examples + echo -------------------- + nosetests tests/load_examples_test.py +} + + +if [[ "$#" -eq "0" ]] +then + echo "No argument suplied" + echo ------------------------ + echo use: + echo "run.sh [options]" + echo "[options]:" + echo "--no-init: Dont restart docker and no db migrations, superset init and test data" + echo "--no-reset-db: Recreates test database (DROP, CREATE)" + exit 1 +fi + +# +# Init global vars +# +DB_NAME="test" +DB_USER="superset" +DB_PASSWORD="superset" +export SUPERSET__SQLALCHEMY_DATABASE_URI=${SUPERSET__SQLALCHEMY_DATABASE_URI:-postgresql+psycopg2://"${DB_USER}":"${DB_PASSWORD}"@localhost/"${DB_NAME}"} +export SUPERSET_CONFIG=${SUPERSET_CONFIG:-tests.superset_test_config} +RUN_INIT=1 +RUN_RESET_DB=1 +TEST_MODULE="${1}" + +# Shift to pass the first cmd parameter for the test module +shift 1 + +PARAMS="" +while (( "$#" )); do + case "$1" in + --no-init) + RUN_INIT=0 + RUN_RESET_DB=0 + shift 1 + ;; + --no-reset-db) + RUN_RESET_DB=0 + shift 1 + ;; + --) # end argument parsing + shift + break + ;; + --*) # unsupported flags + echo "Error: Unsupported flag $1" >&2 + exit 1 + ;; + *) # preserve positional arguments + PARAMS="$PARAMS $1" + shift + ;; + esac +done + +echo ------------------------------------ +echo DB_URI="${SUPERSET__SQLALCHEMY_DATABASE_URI}" +echo Superset config module="${SUPERSET_CONFIG}" +echo Run init procedures=$RUN_INIT +echo Run reset DB=$RUN_RESET_DB +echo Test to run:"${TEST_MODULE}" +echo ------------------------------------ + + +if [ $RUN_RESET_DB -eq 1 ] +then + reset_db +fi + +if [ $RUN_INIT -eq 1 ] +then + test_init +fi + +nosetests --exclude=load_examples_test "${TEST_MODULE}" diff --git a/setup.cfg b/setup.cfg index 46dde49f6ca8..d58d80a1bff1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,7 +45,7 @@ combine_as_imports = true include_trailing_comma = true line_length = 88 known_first_party = superset -known_third_party =alembic,backoff,bleach,celery,click,colorama,contextlib2,croniter,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,geohash,geopy,humanize,isodate,jinja2,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parsedatetime,pathlib2,polyline,prison,pyarrow,pyhive,pytz,retry,selenium,setuptools,simplejson,sphinx_rtd_theme,sqlalchemy,sqlalchemy_utils,sqlparse,werkzeug,wtforms,wtforms_json,yaml +known_third_party =alembic,apispec,backoff,bleach,celery,click,colorama,contextlib2,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,geohash,geopy,humanize,isodate,jinja2,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parsedatetime,pathlib2,polyline,prison,pyarrow,pyhive,pytz,retry,selenium,setuptools,simplejson,sphinx_rtd_theme,sqlalchemy,sqlalchemy_utils,sqlparse,werkzeug,wtforms,wtforms_json,yaml multi_line_output = 3 order_by_type = false @@ -53,7 +53,7 @@ order_by_type = false ignore_missing_imports = true no_implicit_optional = true -[mypy-superset.db_engine_specs.*] +[mypy-superset.bin.*,superset.charts.*,superset.datasets.*,superset.dashboards.*,superset.commands.*,superset.common.*,superset.dao.*,superset.db_engine_specs.*,superset.db_engines.*,superset.examples.*,superset.migrations.*] check_untyped_defs = true disallow_untyped_calls = true disallow_untyped_defs = true diff --git a/setup.py b/setup.py index 053ae819de99..6c1484914eaf 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,7 @@ def get_git_sha(): "croniter>=0.3.28", "cryptography>=2.4.2", "flask>=1.1.0, <2.0.0", - "flask-appbuilder>=2.3.0, <2.4.0", + "flask-appbuilder>=2.3.2, <2.4.0", "flask-caching", "flask-compress", "flask-talisman", @@ -88,7 +88,7 @@ def get_git_sha(): "isodate", "markdown>=3.0", "msgpack>=0.6.1, <0.7.0", - "pandas>=0.25.3, <1.0", + "pandas>=1.0.3, <1.1", "parsedatetime", "pathlib2", "polyline", @@ -100,7 +100,7 @@ def get_git_sha(): "retry>=0.9.2", "selenium>=3.141.0", "simplejson>=3.15.0", - "sqlalchemy>=1.3.5, <2.0", + "sqlalchemy>=1.3.16, <2.0", "sqlalchemy-utils>=0.33.2", "sqlparse>=0.3.0, <0.4", "wtforms-json", @@ -116,8 +116,9 @@ def get_git_sha(): "elasticsearch": ["elasticsearch-dbapi>=0.1.0, <0.2.0"], "druid": ["pydruid==0.5.7", "requests==2.22.0"], "hana": ["hdbcli==2.4.162", "sqlalchemy_hana==0.4.0"], - "dremio": ["sqlalchemy_dremio>=0.5.0dev0"], + "dremio": ["sqlalchemy_dremio>=1.1.0"], "cockroachdb": ["cockroachdb==0.3.3"], + "thumbnails": ["Pillow>=7.0.0, <8.0.0"], }, python_requires="~=3.6", author="Apache Software Foundation", diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js index 714ce6ebd5b3..6b28a21bb29b 100644 --- a/superset-frontend/.eslintrc.js +++ b/superset-frontend/.eslintrc.js @@ -29,6 +29,13 @@ module.exports = { }, plugins: ['prettier', 'react'], overrides: [ + { + files: ['cypress-base/**/*'], + rules: { + 'import/no-unresolved': 0, + 'global-require': 0, + } + }, { files: ['*.ts', '*.tsx'], parser: '@typescript-eslint/parser', @@ -146,6 +153,7 @@ module.exports = { 'no-unused-vars': 0, 'padded-blocks': 0, 'prefer-arrow-callback': 0, + 'prefer-object-spread': 1, 'prefer-template': 0, 'react/forbid-prop-types': 0, 'react/jsx-filename-extension': [1, { extensions: ['.jsx', '.tsx'] }], diff --git a/superset-frontend/babel.config.js b/superset-frontend/babel.config.js index 5d13d8f54a89..1903dbd0f481 100644 --- a/superset-frontend/babel.config.js +++ b/superset-frontend/babel.config.js @@ -65,5 +65,9 @@ module.exports = { ], plugins: ['babel-plugin-dynamic-import-node'], }, + // build instrumented code for testing code coverage with Cypress + instrumented: { + plugins: ['istanbul'], + }, }, }; diff --git a/superset-frontend/cypress-base/.gitignore b/superset-frontend/cypress-base/.gitignore new file mode 100644 index 000000000000..82597972f5bc --- /dev/null +++ b/superset-frontend/cypress-base/.gitignore @@ -0,0 +1,4 @@ +screenshots +.nyc_output +coverage +coverage.json diff --git a/superset-frontend/cypress-base/cypress.json b/superset-frontend/cypress-base/cypress.json index 715717b72747..76e4778f9c7f 100644 --- a/superset-frontend/cypress-base/cypress.json +++ b/superset-frontend/cypress-base/cypress.json @@ -1,13 +1,14 @@ { "baseUrl": "http://localhost:8081", "chromeWebSecurity": false, - "defaultCommandTimeout": 20000, - "requestTimeout": 20000, - "ignoreTestFiles": ["**/!(*.test.js)"], - "projectId": "fbf96q", + "defaultCommandTimeout": 5000, + "requestTimeout": 10000, + "ignoreTestFiles": [ + "**/!(*.test.js)" + ], "video": false, "videoUploadOnPasses": false, "viewportWidth": 1280, - "viewportHeight": 800, - "requestTimeout": 10000 + "viewportHeight": 1024, + "projectId": "ukwxzo" } diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.js b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.js index 913609cdc9a0..028b392e7109 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.js @@ -23,29 +23,15 @@ export default () => beforeEach(() => { cy.server(); cy.login(); - cy.visit(WORLD_HEALTH_DASHBOARD); - cy.get('#app').then(data => { - const bootstrapData = JSON.parse(data[0].dataset.bootstrap); - const dashboard = bootstrapData.dashboard_data; - const dashboardId = dashboard.id; - const boxplotChartId = dashboard.slices.find( - slice => slice.form_data.viz_type === 'box_plot', - ).slice_id; - const formData = `{"slice_id":${boxplotChartId}}`; - const boxplotRequest = `/superset/explore_json/?form_data=${formData}&dashboard_id=${dashboardId}`; - cy.route('POST', boxplotRequest).as('boxplotRequest'); - }); - cy.get('.dashboard-header') .contains('Edit dashboard') .click(); }); it('remove, and add chart flow', () => { - // wait box_plot data and find box plot - cy.wait('@boxplotRequest'); - cy.get('.grid-container .box_plot').should('be.exist'); + // wait for box plot to appear + cy.get('.grid-container .box_plot'); cy.get('.fa.fa-trash') .last() @@ -53,7 +39,6 @@ export default () => cy.wrap($el) .invoke('show') .click(); - // box plot should be gone cy.get('.grid-container .box_plot').should('not.exist'); }); @@ -75,7 +60,7 @@ export default () => .trigger('mousedown', { which: 1 }) .trigger('dragstart', { dataTransfer }) .trigger('drag', {}); - cy.get('.grid-content .dragdroppable') + cy.get('.grid-content div.grid-row.background--transparent') .last() .trigger('dragover', { dataTransfer }) .trigger('drop', { dataTransfer }) diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/filter.js b/superset-frontend/cypress-base/cypress/integration/dashboard/filter.js index c219796e325f..b1920fbe16ef 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/filter.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/filter.js @@ -20,9 +20,12 @@ import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper'; export default () => describe('dashboard filter', () => { - let sliceIds = []; let filterId; - let dashboardId; + let aliases; + + const getAlias = id => { + return `@slice_${id}`; + }; beforeEach(() => { cy.server(); @@ -33,41 +36,32 @@ export default () => cy.get('#app').then(data => { const bootstrapData = JSON.parse(data[0].dataset.bootstrap); const dashboard = bootstrapData.dashboard_data; - dashboardId = dashboard.id; - sliceIds = dashboard.slices.map(slice => slice.slice_id); + const sliceIds = dashboard.slices.map(slice => slice.slice_id); filterId = dashboard.slices.find( slice => slice.form_data.viz_type === 'filter_box', ).slice_id; + aliases = sliceIds.map(id => { + const alias = getAlias(id); + const url = `/superset/explore_json/?*{"slice_id":${id}}*`; + cy.route('POST', url).as(alias.slice(1)); + return alias; + }); + + // wait the initial page load requests + cy.wait(aliases); }); }); it('should apply filter', () => { - const aliases = []; - - const formData = `{"slice_id":${filterId}}`; - const filterRoute = `/superset/explore_json/?form_data=${formData}&dashboard_id=${dashboardId}`; - cy.route('POST', filterRoute).as('fetchFilter'); - cy.wait('@fetchFilter'); - sliceIds - .filter(id => parseInt(id, 10) !== filterId) - .forEach(id => { - const alias = `getJson_${id}`; - aliases.push(`@${alias}`); - - cy.route( - 'POST', - `/superset/explore_json/?form_data={"slice_id":${id}}&dashboard_id=${dashboardId}`, - ).as(alias); - }); - - // select filter_box and apply - cy.get('.Select-control') - .first() + cy.get('.Select-placeholder') + .contains('Select [region]') + .click() + .next() .find('input') - .first() .type('South Asia{enter}', { force: true }); - cy.wait(aliases).then(requests => { + // wait again after applied filters + cy.wait(aliases.filter(x => x !== getAlias(filterId))).then(requests => { requests.forEach(xhr => { const requestFormData = xhr.request.body; const requestParams = JSON.parse(requestFormData.get('form_data')); diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/load.js b/superset-frontend/cypress-base/cypress/integration/dashboard/load.js index 5cb64fe1afde..8b0e642f6e22 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/load.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/load.js @@ -31,16 +31,12 @@ export default () => cy.get('#app').then(data => { const bootstrapData = JSON.parse(data[0].dataset.bootstrap); - const dashboardId = bootstrapData.dashboard_data.id; const slices = bootstrapData.dashboard_data.slices; // then define routes and create alias for each requests slices.forEach(slice => { const alias = `getJson_${slice.slice_id}`; const formData = `{"slice_id":${slice.slice_id}}`; - cy.route( - 'POST', - `/superset/explore_json/?form_data=${formData}&dashboard_id=${dashboardId}`, - ).as(alias); + cy.route('POST', `/superset/explore_json/?*${formData}*`).as(alias); aliases.push(`@${alias}`); }); }); @@ -49,12 +45,15 @@ export default () => it('should load dashboard', () => { // wait and verify one-by-one cy.wait(aliases).then(requests => { - requests.forEach(async xhr => { - expect(xhr.status).to.eq(200); - const responseBody = await readResponseBlob(xhr.response.body); - expect(responseBody).to.have.property('error', null); - cy.get(`#slice-container-${xhr.response.body.form_data.slice_id}`); - }); + return Promise.all( + requests.map(async xhr => { + expect(xhr.status).to.eq(200); + const responseBody = await readResponseBlob(xhr.response.body); + expect(responseBody).to.have.property('error', null); + const sliceId = responseBody.form_data.slice_id; + cy.get(`#chart-id-${sliceId}`).should('be.visible'); + }), + ); }); }); }); diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/save.js b/superset-frontend/cypress-base/cypress/integration/dashboard/save.js index 03dec465bd99..73b5431b062c 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/save.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/save.js @@ -52,7 +52,6 @@ export default () => it('should save as new dashboard', () => { cy.wait('@copyRequest').then(xhr => { expect(xhr.status).to.eq(200); - readResponseBlob(xhr.response.body).then(json => { expect(json.id).to.be.gt(dashboardId); }); @@ -61,11 +60,7 @@ export default () => it('should save/overwrite dashboard', () => { // should have box_plot chart - const formData = `{"slice_id":${boxplotChartId}}`; - const boxplotRequest = `/superset/explore_json/?form_data=${formData}&dashboard_id=${dashboardId}`; - cy.route('POST', boxplotRequest).as('boxplotRequest'); - cy.wait('@boxplotRequest'); - cy.get('.grid-container .box_plot').should('be.exist'); + cy.get('.grid-container .box_plot', { timeout: 5000 }); // wait for 5 secs // remove box_plot chart from dashboard cy.get('.dashboard-header') diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/tabs.js b/superset-frontend/cypress-base/cypress/integration/dashboard/tabs.js index 51dfa54d231b..e5e05384da89 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/tabs.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/tabs.js @@ -117,8 +117,9 @@ export default () => .last() .find('.editable-title input') .click(); - cy.wait('@boxplotRequest'); - cy.get('.grid-container .box_plot').should('be.exist'); + + // should exist a visible box_plot element + cy.get('.grid-container .box_plot'); }); it('should send new queries when tab becomes visible', () => { @@ -166,6 +167,7 @@ export default () => .last() .find('.editable-title input') .click(); + cy.wait('@boxplotRequest').then(xhr => { const requestFormData = xhr.request.body; const requestParams = JSON.parse(requestFormData.get('form_data')); @@ -190,11 +192,12 @@ export default () => // trigger 1 new query cy.wait('@treemapRequest'); - // no other requests occurred + // make sure query API not requested multiple times cy.on('fail', err => { - expect(err.message).to.include('Timed out retrying'); + expect(err.message).to.include('timed out waiting'); return false; }); + cy.wait('@boxplotRequest', { timeout: 1000 }).then(() => { throw new Error('Unexpected API call.'); }); diff --git a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/big_number.js b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/big_number.js index 61c8fc654262..ede0659cd32b 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/big_number.js +++ b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/big_number.js @@ -56,8 +56,8 @@ export default () => it('should work', () => { verify(BIG_NUMBER_FORM_DATA); - cy.get('.chart-container .header_line'); - cy.get('.chart-container .subheader_line'); + cy.get('.chart-container .header-line'); + cy.get('.chart-container .subheader-line'); cy.get('.chart-container svg path.vx-linepath'); }); @@ -66,8 +66,8 @@ export default () => ...BIG_NUMBER_FORM_DATA, compare_lag: null, }); - cy.get('.chart-container .header_line'); - cy.get('.chart-container .subheader_line'); + cy.get('.chart-container .header-line'); + cy.get('.chart-container .subheader-line').should('not.exist'); cy.get('.chart-container svg path.vx-linepath'); }); @@ -76,10 +76,8 @@ export default () => ...BIG_NUMBER_FORM_DATA, show_trend_line: false, }); - cy.get('.chart-container .header_line'); - cy.get('.chart-container .subheader_line'); - cy.get('.chart-container').then(containers => { - expect(containers[0].querySelector('svg')).to.equal(null); - }); + cy.get('.chart-container .header-line'); + cy.get('.chart-container .subheader-line'); + cy.get('.chart-container svg').should('not.exist'); }); }); diff --git a/superset-frontend/cypress-base/cypress/plugins/index.js b/superset-frontend/cypress-base/cypress/plugins/index.js index 4efd00b01ae8..adfeabedbc28 100644 --- a/superset-frontend/cypress-base/cypress/plugins/index.js +++ b/superset-frontend/cypress-base/cypress/plugins/index.js @@ -29,7 +29,7 @@ // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) -module.exports = (/* on, config */) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config +module.exports = (on, config) => { + require('@cypress/code-coverage/task')(on, config); + return config; }; diff --git a/superset-frontend/cypress-base/cypress/support/commands.js b/superset-frontend/cypress-base/cypress/support/commands.js index 1c12e4fb20d4..fafff6cb94b9 100644 --- a/superset-frontend/cypress-base/cypress/support/commands.js +++ b/superset-frontend/cypress-base/cypress/support/commands.js @@ -83,13 +83,11 @@ Cypress.Commands.add('verifyResponseCodes', async xhr => { Cypress.Commands.add('verifySliceContainer', chartSelector => { // After a wait response check for valid slice container - cy.get('.slice_container').within(() => { + cy.get('.slice_container').within(async () => { if (chartSelector) { - cy.get(chartSelector).then(charts => { - const firstChart = charts[0]; - expect(firstChart.clientWidth).greaterThan(0); - expect(firstChart.clientHeight).greaterThan(0); - }); + const chart = await cy.get(chartSelector); + expect(chart[0].clientWidth).greaterThan(0); + expect(chart[0].clientHeight).greaterThan(0); } }); }); diff --git a/superset-frontend/cypress-base/cypress/support/index.js b/superset-frontend/cypress-base/cypress/support/index.js index 9ff4b7b1a488..52bd671616a7 100644 --- a/superset-frontend/cypress-base/cypress/support/index.js +++ b/superset-frontend/cypress-base/cypress/support/index.js @@ -31,6 +31,7 @@ // https://on.cypress.io/configuration // *********************************************************** +import '@cypress/code-coverage/support'; import './commands'; // The following is a workaround for Cypress not supporting fetch. diff --git a/superset-frontend/cypress-base/package-lock.json b/superset-frontend/cypress-base/package-lock.json index 7a31042f812e..a0ca3b0936ee 100644 --- a/superset-frontend/cypress-base/package-lock.json +++ b/superset-frontend/cypress-base/package-lock.json @@ -4,517 +4,3836 @@ "lockfileVersion": 1, "requires": true, "dependencies": { - "@cypress/listr-verbose-renderer": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz", - "integrity": "sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo=", + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, "requires": { - "chalk": "^1.1.3", - "cli-cursor": "^1.0.2", - "date-fns": "^1.27.2", - "figures": "^1.7.0" - }, - "dependencies": { - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" - } + "@babel/highlight": "^7.8.3" } }, - "@cypress/xvfb": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", - "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", + "@babel/core": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.4.5.tgz", + "integrity": "sha512-OvjIh6aqXtlsA8ujtGKfC7LYWksYSX8yQcM8Ay3LuvVeQ63lcOKgoZWVqcpFwkd29aYU9rVx7jxhfhiEDV9MZA==", + "dev": true, "requires": { - "debug": "^3.1.0", - "lodash.once": "^4.1.1" + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.4.4", + "@babel/helpers": "^7.4.4", + "@babel/parser": "^7.4.5", + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.4.5", + "@babel/types": "^7.4.4", + "convert-source-map": "^1.1.0", + "debug": "^4.1.0", + "json5": "^2.1.0", + "lodash": "^4.17.11", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" } }, - "@types/sizzle": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", - "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==" + "@babel/generator": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.5.tgz", + "integrity": "sha512-GbNIxVB3ZJe3tLeDm1HSn2AhuD/mVcyLDpgtLXa5tplmWrJdF/elxB56XNqCuD6szyNkDi6wuoKXln3QeBmCHQ==", + "dev": true, + "requires": { + "@babel/types": "^7.9.5", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } }, - "ajv": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", - "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "@babel/helper-annotate-as-pure": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz", + "integrity": "sha512-6o+mJrZBxOoEX77Ezv9zwW7WV8DdluouRKNY/IR5u/YTMuKHgugHOzYWlYvYLpLA9nPsQCAAASpCIbjI9Mv+Uw==", + "dev": true, "requires": { - "fast-deep-equal": "^2.0.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@babel/types": "^7.8.3" } }, - "ansi-escapes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", - "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=" + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.8.3.tgz", + "integrity": "sha512-5eFOm2SyFPK4Rh3XMMRDjN7lBH0orh3ss0g3rTYZnBQ+r6YPj7lgDyCvPphynHvUrobJmeMignBr6Acw9mAPlw==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.8.3", + "@babel/types": "^7.8.3" + } }, - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + "@babel/helper-builder-react-jsx": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.9.0.tgz", + "integrity": "sha512-weiIo4gaoGgnhff54GQ3P5wsUQmnSwpkvU0r6ZHq6TzoSzKy4JxHEgnxNytaKbov2a9z/CVNyzliuCOUPEX3Jw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/types": "^7.9.0" + } }, - "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + "@babel/helper-builder-react-jsx-experimental": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.9.5.tgz", + "integrity": "sha512-HAagjAC93tk748jcXpZ7oYRZH485RCq/+yEv9SIWezHRPv9moZArTnkUNciUNzvwHUABmiWKlcxJvMcu59UwTg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-module-imports": "^7.8.3", + "@babel/types": "^7.9.5" + } }, - "arch": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.1.1.tgz", - "integrity": "sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg==" + "@babel/helper-create-class-features-plugin": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.9.5.tgz", + "integrity": "sha512-IipaxGaQmW4TfWoXdqjY0TzoXQ1HRS0kPpEgvjosb3u7Uedcq297xFqDQiCcQtRRwzIMif+N1MLVI8C5a4/PAA==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.9.5", + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.6", + "@babel/helper-split-export-declaration": "^7.8.3" + } }, - "asn1": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", - "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "@babel/helper-create-regexp-features-plugin": { + "version": "7.8.8", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.8.tgz", + "integrity": "sha512-LYVPdwkrQEiX9+1R29Ld/wTrmQu1SSKYnuOk3g0CkcZMA1p0gsNxJFj/3gBdaJ7Cg0Fnek5z0DsMULePP7Lrqg==", + "dev": true, "requires": { - "safer-buffer": "~2.1.0" + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-regex": "^7.8.3", + "regexpu-core": "^4.7.0" } }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + "@babel/helper-define-map": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.8.3.tgz", + "integrity": "sha512-PoeBYtxoZGtct3md6xZOCWPcKuMuk3IHhgxsRRNtnNShebf4C8YonTSblsK4tvDbm+eJAw2HAPOfCr+Q/YRG/g==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.8.3", + "@babel/types": "^7.8.3", + "lodash": "^4.17.13" + } }, - "async": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", - "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "@babel/helper-explode-assignable-expression": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.8.3.tgz", + "integrity": "sha512-N+8eW86/Kj147bO9G2uclsg5pwfs/fqqY5rwgIL7eTBklgXjcOJ3btzS5iM6AitJcftnY7pm2lGsrJVYLGjzIw==", + "dev": true, "requires": { - "lodash": "^4.17.10" + "@babel/traverse": "^7.8.3", + "@babel/types": "^7.8.3" } }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "@babel/helper-function-name": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.9.5.tgz", + "integrity": "sha512-JVcQZeXM59Cd1qanDUxv9fgJpt3NeKUaqBqUEvfmQ+BCOKq2xUgaWZW2hr0dkbyJgezYuplEoh5knmrnS68efw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.9.5" + } }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } }, - "aws4": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", - "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" + "@babel/helper-hoist-variables": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.8.3.tgz", + "integrity": "sha512-ky1JLOjcDUtSc+xkt0xhYff7Z6ILTAHKmZLHPxAhOP0Nd77O+3nCsd6uSVYur6nJnCI029CrNbYlc0LoPfAPQg==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } }, - "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "@babel/helper-member-expression-to-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz", + "integrity": "sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "@babel/helper-module-imports": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz", + "integrity": "sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==", + "dev": true, "requires": { - "tweetnacl": "^0.14.3" + "@babel/types": "^7.8.3" } }, - "bluebird": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz", - "integrity": "sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw=" + "@babel/helper-module-transforms": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.9.0.tgz", + "integrity": "sha512-0FvKyu0gpPfIQ8EkxlrAydOWROdHpBmiCiRwLkUiBGhCUPRRbVD2/tm3sFr/c/GWFrQ/ffutGUAnx7V0FzT2wA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.6", + "@babel/helper-simple-access": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/template": "^7.8.6", + "@babel/types": "^7.9.0", + "lodash": "^4.17.13" + } }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "@babel/helper-optimise-call-expression": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz", + "integrity": "sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==", + "dev": true, "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@babel/types": "^7.8.3" } }, - "buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + "@babel/helper-plugin-utils": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz", + "integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==", + "dev": true + }, + "@babel/helper-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.8.3.tgz", + "integrity": "sha512-BWt0QtYv/cg/NecOAZMdcn/waj/5P26DR4mVLXfFtDokSR6fyuG0Pj+e2FqtSME+MqED1khnSMulkmGl8qWiUQ==", + "dev": true, + "requires": { + "lodash": "^4.17.13" + } }, - "buffer-from": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + "@babel/helper-remap-async-to-generator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.8.3.tgz", + "integrity": "sha512-kgwDmw4fCg7AVgS4DukQR/roGp+jP+XluJE5hsRZwxCYGg+Rv9wSGErDWhlI90FODdYfd4xG4AQRiMDjjN0GzA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-wrap-function": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.8.3", + "@babel/types": "^7.8.3" + } }, - "cachedir": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-1.3.0.tgz", - "integrity": "sha512-O1ji32oyON9laVPJL1IZ5bmwd2cB46VfpxkDequezH+15FDzzVddEyrGEeX4WusDSqKxdyFdDQDEG1yo1GoWkg==", + "@babel/helper-replace-supers": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.8.6.tgz", + "integrity": "sha512-PeMArdA4Sv/Wf4zXwBKPqVj7n9UF/xg6slNRtZW84FM7JpE1CbG8B612FyM4cxrf4fMAMGO0kR7voy1ForHHFA==", + "dev": true, "requires": { - "os-homedir": "^1.0.1" + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/traverse": "^7.8.6", + "@babel/types": "^7.8.6" } }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + "@babel/helper-simple-access": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz", + "integrity": "sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw==", + "dev": true, + "requires": { + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - } + "@babel/types": "^7.8.3" } }, - "check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=" + "@babel/helper-validator-identifier": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz", + "integrity": "sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz", + "integrity": "sha512-LACJrbUET9cQDzb6kG7EeD7+7doC3JNvUgTEQOx2qaO1fKlzE/Bf05qs9w1oXQMmXlPO65lC3Tq9S6gZpTErEQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.8.3", + "@babel/types": "^7.8.3" + } }, - "ci-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", - "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==" + "@babel/helpers": { + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.9.2.tgz", + "integrity": "sha512-JwLvzlXVPjO8eU9c/wF9/zOIN7X6h8DYf7mG4CiFRZRvZNKEF5dQ3H3V+ASkHoIB3mWhatgl5ONhyqHRI6MppA==", + "dev": true, + "requires": { + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.9.0", + "@babel/types": "^7.9.0" + } }, - "cli-cursor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", - "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, "requires": { - "restore-cursor": "^1.0.1" + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" } }, - "cli-spinners": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-0.1.2.tgz", - "integrity": "sha1-u3ZNiOGF+54eaiofGXcjGPYF4xw=" + "@babel/parser": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.4.tgz", + "integrity": "sha512-bC49otXX6N0/VYhgOMh4gnP26E9xnDZK3TmbNpxYzzz9BQLBosQwfyOe9/cXUU3txYhTzLCbcqd5c8y/OmCjHA==", + "dev": true + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz", + "integrity": "sha512-NZ9zLv848JsV3hs8ryEh7Uaz/0KsmPLqv0+PdkDJL1cJy0K4kOCFa8zc1E3mp+RHPQcpdfb/6GovEsW4VDrOMw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-remap-async-to-generator": "^7.8.3", + "@babel/plugin-syntax-async-generators": "^7.8.0" + } }, - "cli-truncate": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz", - "integrity": "sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ=", + "@babel/plugin-proposal-class-properties": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.3.0.tgz", + "integrity": "sha512-wNHxLkEKTQ2ay0tnsam2z7fGZUi+05ziDJflEt3AZTP3oXLKHJp9HqhfroB/vdMvt3sda9fAbq7FsG8QPDrZBg==", + "dev": true, "requires": { - "slice-ansi": "0.0.4", - "string-width": "^1.0.1" + "@babel/helper-create-class-features-plugin": "^7.3.0", + "@babel/helper-plugin-utils": "^7.0.0" } }, - "code-point-at": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + "@babel/plugin-proposal-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.8.3.tgz", + "integrity": "sha512-KGhQNZ3TVCQG/MjRbAUwuH+14y9q0tpxs1nWWs3pbSleRdDro9SAMMDyye8HhY1gqZ7/NqIc8SKhya0wRDgP1Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.0" + } }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.3.2.tgz", + "integrity": "sha512-DjeMS+J2+lpANkYLLO+m6GjoTMygYglKmRe6cDTbFv3L9i6mmiE8fe6B8MtCSLZpVXscD5kn7s6SgtHrDoBWoA==", + "dev": true, "requires": { - "color-name": "1.1.3" + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-object-rest-spread": "^7.2.0" } }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-0gkX7J7E+AtAw9fcwlVQj8peP61qhdg/89D5swOkjYbkboA2CVckn3kiyum1DE0wskGb7KJJxBdyEBApDLLVdw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.0" + } }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.8.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.8.8.tgz", + "integrity": "sha512-EVhjVsMpbhLw9ZfHWSx2iy13Q8Z/eg8e8ccVWt23sWQK5l1UdkoLJPN5w69UA4uITGBnEZD2JOe4QOHycYKv8A==", + "dev": true, "requires": { - "delayed-stream": "~1.0.0" + "@babel/helper-create-regexp-features-plugin": "^7.8.8", + "@babel/helper-plugin-utils": "^7.8.3" } }, - "commander": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", - "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==" + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } }, - "common-tags": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz", - "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==" + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "@babel/plugin-syntax-jsx": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.8.3.tgz", + "integrity": "sha512-WxdW9xyLgBdefoo0Ynn3MRSkhe5tFVxxKNVdnZSh318WrG2e2jH+E9wd/++JsqcLJZPfz87njQJ8j2Upjm0M0A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } }, - "concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, "requires": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" + "@babel/helper-plugin-utils": "^7.8.0" } }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } }, - "cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "@babel/plugin-transform-arrow-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz", + "integrity": "sha512-0MRF+KC8EqH4dbuITCWwPSzsyO3HIWWlm30v8BbbpOrS1B++isGxPnnuq/IZvOX5J2D/p7DQalQm+/2PnlKGxg==", + "dev": true, "requires": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "@babel/helper-plugin-utils": "^7.8.3" } }, - "cypress": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-3.6.1.tgz", - "integrity": "sha512-6n0oqENdz/oQ7EJ6IgESNb2M7Bo/70qX9jSJsAziJTC3kICfEMmJUlrAnP9bn+ut24MlXQST5nRXhUP5nRIx6A==", + "@babel/plugin-transform-async-to-generator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.8.3.tgz", + "integrity": "sha512-imt9tFLD9ogt56Dd5CI/6XgpukMwd/fLGSrix2httihVe7LOGVPhyhMh1BU5kDM7iHD08i8uUtmV2sWaBFlHVQ==", + "dev": true, "requires": { - "@cypress/listr-verbose-renderer": "0.4.1", - "@cypress/xvfb": "1.2.4", - "@types/sizzle": "2.3.2", - "arch": "2.1.1", - "bluebird": "3.5.0", - "cachedir": "1.3.0", - "chalk": "2.4.2", - "check-more-types": "2.24.0", - "commander": "2.15.1", - "common-tags": "1.8.0", - "debug": "3.2.6", - "execa": "0.10.0", - "executable": "4.1.1", - "extract-zip": "1.6.7", - "fs-extra": "5.0.0", - "getos": "3.1.1", - "is-ci": "1.2.1", - "is-installed-globally": "0.1.0", - "lazy-ass": "1.6.0", - "listr": "0.12.0", - "lodash": "4.17.15", - "log-symbols": "2.2.0", - "minimist": "1.2.0", - "moment": "2.24.0", - "ramda": "0.24.1", - "request": "2.88.0", - "request-progress": "3.0.0", - "supports-color": "5.5.0", - "tmp": "0.1.0", - "untildify": "3.0.3", - "url": "0.11.0", - "yauzl": "2.10.0" + "@babel/helper-module-imports": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-remap-async-to-generator": "^7.8.3" } }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.8.3.tgz", + "integrity": "sha512-vo4F2OewqjbB1+yaJ7k2EJFHlTP3jR634Z9Cj9itpqNjuLXvhlVxgnjsHsdRgASR8xYDrx6onw4vW5H6We0Jmg==", + "dev": true, "requires": { - "assert-plus": "^1.0.0" + "@babel/helper-plugin-utils": "^7.8.3" } }, - "date-fns": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", - "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" + "@babel/plugin-transform-block-scoping": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.8.3.tgz", + "integrity": "sha512-pGnYfm7RNRgYRi7bids5bHluENHqJhrV4bCZRwc5GamaWIIs07N4rZECcmJL6ZClwjDz1GbdMZFtPs27hTB06w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "lodash": "^4.17.13" + } }, - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "@babel/plugin-transform-classes": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.9.5.tgz", + "integrity": "sha512-x2kZoIuLC//O5iA7PEvecB105o7TLzZo8ofBVhP79N+DO3jaX+KYfww9TQcfBEZD0nikNyYcGB1IKtRq36rdmg==", + "dev": true, "requires": { - "ms": "^2.1.1" + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-define-map": "^7.8.3", + "@babel/helper-function-name": "^7.9.5", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.6", + "@babel/helper-split-export-declaration": "^7.8.3", + "globals": "^11.1.0" } }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + "@babel/plugin-transform-computed-properties": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.8.3.tgz", + "integrity": "sha512-O5hiIpSyOGdrQZRQ2ccwtTVkgUDBBiCuK//4RJ6UfePllUTCENOzKxfh6ulckXKc0DixTFLCfb2HVkNA7aDpzA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "@babel/plugin-transform-destructuring": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.9.5.tgz", + "integrity": "sha512-j3OEsGel8nHL/iusv/mRd5fYZ3DrOxWC82x0ogmdN/vHfAP4MYw+AFKYanzWlktNwikKvlzUV//afBW5FTp17Q==", + "dev": true, "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" + "@babel/helper-plugin-utils": "^7.8.3" } }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.8.3.tgz", + "integrity": "sha512-kLs1j9Nn4MQoBYdRXH6AeaXMbEJFaFu/v1nQkvib6QzTj8MZI5OQzqmD83/2jEM1z0DLilra5aWO5YpyC0ALIw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.8.3.tgz", + "integrity": "sha512-s8dHiBUbcbSgipS4SMFuWGqCvyge5V2ZeAWzR6INTVC3Ltjig/Vw1G2Gztv0vU/hRG9X8IvKvYdoksnUfgXOEQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.8.3.tgz", + "integrity": "sha512-zwIpuIymb3ACcInbksHaNcR12S++0MDLKkiqXHl3AzpgdKlFNhog+z/K0+TGW+b0w5pgTq4H6IwV/WhxbGYSjQ==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.9.0.tgz", + "integrity": "sha512-lTAnWOpMwOXpyDx06N+ywmF3jNbafZEqZ96CGYabxHrxNX8l5ny7dt4bK/rGwAh9utyP2b2Hv7PlZh1AAS54FQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.8.3.tgz", + "integrity": "sha512-rO/OnDS78Eifbjn5Py9v8y0aR+aSYhDhqAwVfsTl0ERuMZyr05L1aFSCJnbv2mmsLkit/4ReeQ9N2BgLnOcPCQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.8.3.tgz", + "integrity": "sha512-3Tqf8JJ/qB7TeldGl+TT55+uQei9JfYaregDcEAyBZ7akutriFrt6C/wLYIer6OYhleVQvH/ntEhjE/xMmy10A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.8.3.tgz", + "integrity": "sha512-3Wk2EXhnw+rP+IDkK6BdtPKsUE5IeZ6QOGrPYvw52NwBStw9V1ZVzxgK6fSKSxqUvH9eQPR3tm3cOq79HlsKYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.9.0.tgz", + "integrity": "sha512-vZgDDF003B14O8zJy0XXLnPH4sg+9X5hFBBGN1V+B2rgrB+J2xIypSN6Rk9imB2hSTHQi5OHLrFWsZab1GMk+Q==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.9.0.tgz", + "integrity": "sha512-qzlCrLnKqio4SlgJ6FMMLBe4bySNis8DFn1VkGmOcxG9gqEyPIOzeQrA//u0HAKrWpJlpZbZMPB1n/OPa4+n8g==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-simple-access": "^7.8.3", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.9.0.tgz", + "integrity": "sha512-FsiAv/nao/ud2ZWy4wFacoLOm5uxl0ExSQ7ErvP7jpoihLR6Cq90ilOFyX9UXct3rbtKsAiZ9kFt5XGfPe/5SQ==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.8.3", + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.9.0.tgz", + "integrity": "sha512-uTWkXkIVtg/JGRSIABdBoMsoIeoHQHPTL0Y2E7xf5Oj7sLqwVsNXOkNk0VJc7vF0IMBsPeikHxFjGe+qmwPtTQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.8.3.tgz", + "integrity": "sha512-f+tF/8UVPU86TrCb06JoPWIdDpTNSGGcAtaD9mLP0aYGA0OS0j7j7DHJR0GTFrUZPUU6loZhbsVZgTh0N+Qdnw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.8.3" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.8.3.tgz", + "integrity": "sha512-QuSGysibQpyxexRyui2vca+Cmbljo8bcRckgzYV4kRIsHpVeyeC3JDO63pY+xFZ6bWOBn7pfKZTqV4o/ix9sFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.8.3.tgz", + "integrity": "sha512-57FXk+gItG/GejofIyLIgBKTas4+pEU47IXKDBWFTxdPd7F80H8zybyAY7UoblVfBhBGs2EKM+bJUu2+iUYPDQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.3" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.9.5.tgz", + "integrity": "sha512-0+1FhHnMfj6lIIhVvS4KGQJeuhe1GI//h5uptK4PvLt+BGBxsoUJbd3/IW002yk//6sZPlFgsG1hY6OHLcy6kA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.8.3.tgz", + "integrity": "sha512-uGiiXAZMqEoQhRWMK17VospMZh5sXWg+dlh2soffpkAl96KAm+WZuJfa6lcELotSRmooLqg0MWdH6UUq85nmmg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.8.3.tgz", + "integrity": "sha512-3Jy/PCw8Fe6uBKtEgz3M82ljt+lTg+xJaM4og+eyu83qLT87ZUSckn0wy7r31jflURWLO83TW6Ylf7lyXj3m5A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.9.4.tgz", + "integrity": "sha512-Mjqf3pZBNLt854CK0C/kRuXAnE6H/bo7xYojP+WGtX8glDGSibcwnsWwhwoSuRg0+EBnxPC1ouVnuetUIlPSAw==", + "dev": true, + "requires": { + "@babel/helper-builder-react-jsx": "^7.9.0", + "@babel/helper-builder-react-jsx-experimental": "^7.9.0", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-jsx": "^7.8.3" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.9.0.tgz", + "integrity": "sha512-K2ObbWPKT7KUTAoyjCsFilOkEgMvFG+y0FqOl6Lezd0/13kMkkjHskVsZvblRPj1PHA44PrToaZANrryppzTvQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-jsx": "^7.8.3" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.9.0.tgz", + "integrity": "sha512-K6m3LlSnTSfRkM6FcRk8saNEeaeyG5k7AVkBU2bZK3+1zdkSED3qNdsWrUgQBeTVD2Tp3VMmerxVO2yM5iITmw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-jsx": "^7.8.3" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.8.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.8.7.tgz", + "integrity": "sha512-TIg+gAl4Z0a3WmD3mbYSk+J9ZUH6n/Yc57rtKRnlA/7rcCvpekHXe0CMZHP1gYp7/KLe9GHTuIba0vXmls6drA==", + "dev": true, + "requires": { + "regenerator-transform": "^0.14.2" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.8.3.tgz", + "integrity": "sha512-mwMxcycN3omKFDjDQUl+8zyMsBfjRFr0Zn/64I41pmjv4NJuqcYlEtezwYtw9TFd9WR1vN5kiM+O0gMZzO6L0A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-runtime": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.2.0.tgz", + "integrity": "sha512-jIgkljDdq4RYDnJyQsiWbdvGeei/0MOTtSHKO/rfbd/mXBxNpdlulMx49L0HQ4pug1fXannxoqCI+fYSle9eSw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "resolve": "^1.8.1", + "semver": "^5.5.1" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.8.3.tgz", + "integrity": "sha512-I9DI6Odg0JJwxCHzbzW08ggMdCezoWcuQRz3ptdudgwaHxTjxw5HgdFJmZIkIMlRymL6YiZcped4TTCB0JcC8w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.8.3.tgz", + "integrity": "sha512-CkuTU9mbmAoFOI1tklFWYYbzX5qCIZVXPVy0jpXgGwkplCndQAa58s2jr66fTeQnA64bDox0HL4U56CFYoyC7g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.8.3.tgz", + "integrity": "sha512-9Spq0vGCD5Bb4Z/ZXXSK5wbbLFMG085qd2vhL1JYu1WcQ5bXqZBAYRzU1d+p79GcHs2szYv5pVQCX13QgldaWw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-regex": "^7.8.3" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.8.3.tgz", + "integrity": "sha512-820QBtykIQOLFT8NZOcTRJ1UNuztIELe4p9DCgvj4NK+PwluSJ49we7s9FB1HIGNIYT7wFUJ0ar2QpCDj0escQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.8.4.tgz", + "integrity": "sha512-2QKyfjGdvuNfHsb7qnBBlKclbD4CfshH2KvDabiijLMGXPHJXGxtDzwIF7bQP+T0ysw8fYTtxPafgfs/c1Lrqg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz", + "integrity": "sha512-+ufgJjYdmWfSQ+6NS9VGUR2ns8cjJjYbrbi11mZBTaWm+Fui/ncTLFF28Ei1okavY+xkojGr1eJxNsWYeA5aZw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/preset-env": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.4.5.tgz", + "integrity": "sha512-f2yNVXM+FsR5V8UwcFeIHzHWgnhXg3NpRmy0ADvALpnhB0SLbCvrCRr4BLOUYbQNLS+Z0Yer46x9dJXpXewI7w==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-json-strings": "^7.2.0", + "@babel/plugin-proposal-object-rest-spread": "^7.4.4", + "@babel/plugin-proposal-optional-catch-binding": "^7.2.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-syntax-async-generators": "^7.2.0", + "@babel/plugin-syntax-json-strings": "^7.2.0", + "@babel/plugin-syntax-object-rest-spread": "^7.2.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.2.0", + "@babel/plugin-transform-async-to-generator": "^7.4.4", + "@babel/plugin-transform-block-scoped-functions": "^7.2.0", + "@babel/plugin-transform-block-scoping": "^7.4.4", + "@babel/plugin-transform-classes": "^7.4.4", + "@babel/plugin-transform-computed-properties": "^7.2.0", + "@babel/plugin-transform-destructuring": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/plugin-transform-duplicate-keys": "^7.2.0", + "@babel/plugin-transform-exponentiation-operator": "^7.2.0", + "@babel/plugin-transform-for-of": "^7.4.4", + "@babel/plugin-transform-function-name": "^7.4.4", + "@babel/plugin-transform-literals": "^7.2.0", + "@babel/plugin-transform-member-expression-literals": "^7.2.0", + "@babel/plugin-transform-modules-amd": "^7.2.0", + "@babel/plugin-transform-modules-commonjs": "^7.4.4", + "@babel/plugin-transform-modules-systemjs": "^7.4.4", + "@babel/plugin-transform-modules-umd": "^7.2.0", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.4.5", + "@babel/plugin-transform-new-target": "^7.4.4", + "@babel/plugin-transform-object-super": "^7.2.0", + "@babel/plugin-transform-parameters": "^7.4.4", + "@babel/plugin-transform-property-literals": "^7.2.0", + "@babel/plugin-transform-regenerator": "^7.4.5", + "@babel/plugin-transform-reserved-words": "^7.2.0", + "@babel/plugin-transform-shorthand-properties": "^7.2.0", + "@babel/plugin-transform-spread": "^7.2.0", + "@babel/plugin-transform-sticky-regex": "^7.2.0", + "@babel/plugin-transform-template-literals": "^7.4.4", + "@babel/plugin-transform-typeof-symbol": "^7.2.0", + "@babel/plugin-transform-unicode-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "browserslist": "^4.6.0", + "core-js-compat": "^3.1.1", + "invariant": "^2.2.2", + "js-levenshtein": "^1.1.3", + "semver": "^5.5.0" + }, + "dependencies": { + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.9.5.tgz", + "integrity": "sha512-VP2oXvAf7KCYTthbUHwBlewbl1Iq059f6seJGsxMizaCdgHIeczOr7FBqELhSqfkIl04Fi8okzWzl63UKbQmmg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-transform-parameters": "^7.9.5" + } + } + } + }, + "@babel/preset-react": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.0.0.tgz", + "integrity": "sha512-oayxyPS4Zj+hF6Et11BwuBkmpgT/zMxyuZgFrMeZID6Hdh3dGlk4sHCAhdBCpuCKW2ppBfl2uCCetlrUIJRY3w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-transform-react-display-name": "^7.0.0", + "@babel/plugin-transform-react-jsx": "^7.0.0", + "@babel/plugin-transform-react-jsx-self": "^7.0.0", + "@babel/plugin-transform-react-jsx-source": "^7.0.0" + } + }, + "@babel/runtime": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.3.1.tgz", + "integrity": "sha512-7jGW8ppV0ant637pIqAcFfQDDH1orEPGJb8aXfUozuCU3QqX7rX4DA8iwrbPrR1hcH0FTTHz47yQnk+bl5xHQA==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.12.0" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", + "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==", + "dev": true + } + } + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.5.tgz", + "integrity": "sha512-c4gH3jsvSuGUezlP6rzSJ6jf8fYjLj3hsMZRx/nX0h+fmHN0w+ekubRrHPqnMec0meycA2nwCsJ7dC8IPem2FQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.5", + "@babel/helper-function-name": "^7.9.5", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.0", + "@babel/types": "^7.9.5", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.5.tgz", + "integrity": "sha512-XjnvNqenk818r5zMaba+sLQjnbda31UfUURv3ei0qPQw4u+j2jMyJ5b11y8ZHYTRSI3NnInQkkkRT4fLqqPdHg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.5", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "@cypress/browserify-preprocessor": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cypress/browserify-preprocessor/-/browserify-preprocessor-2.2.1.tgz", + "integrity": "sha512-97vJ1ulp6sIBJ00FJHAP8JDrJmBXV1UudNNs5r2LmXl5ESiVrPc/5wv5zfJuW2toOSOHa9IbJpwJj/4RbvRYXg==", + "dev": true, + "requires": { + "@babel/core": "7.4.5", + "@babel/plugin-proposal-class-properties": "7.3.0", + "@babel/plugin-proposal-object-rest-spread": "7.3.2", + "@babel/plugin-transform-runtime": "7.2.0", + "@babel/preset-env": "7.4.5", + "@babel/preset-react": "7.0.0", + "@babel/runtime": "7.3.1", + "babel-plugin-add-module-exports": "1.0.2", + "babelify": "10.0.0", + "bluebird": "3.5.3", + "browserify": "16.2.3", + "coffeeify": "3.0.1", + "coffeescript": "1.12.7", + "debug": "4.1.1", + "fs-extra": "7.0.1", + "lodash.clonedeep": "4.5.0", + "through2": "^2.0.0", + "watchify": "3.11.1" + }, + "dependencies": { + "bluebird": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.3.tgz", + "integrity": "sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==", + "dev": true + }, + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + } + } + }, + "@cypress/code-coverage": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@cypress/code-coverage/-/code-coverage-3.1.0.tgz", + "integrity": "sha512-LeAEA8iyAubn6BhFP24QH6ogOPKBqkuzn0+wZRlY+MG7tFUKqVmt8pWbBGzVn7hIF7YnjBFleQ24VnIiA3CscQ==", + "dev": true, + "requires": { + "@cypress/browserify-preprocessor": "2.2.1", + "debug": "4.1.1", + "execa": "4.0.0", + "istanbul-lib-coverage": "3.0.0", + "nyc": "15.0.1" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.2.tgz", + "integrity": "sha512-PD6G8QG3S4FK/XCGFbEQrDqO2AnMMsy0meR7lerlIOHAAbkuavGU/pOqprrlvfTNjvowivTeBsjebAL0NSoMxw==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "execa": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.0.0.tgz", + "integrity": "sha512-JbDUxwV3BoT5ZVXQrSVbAiaXhXUkIwvbhPIwZ0N13kX+5yCzOhUNdocxB/UQRuYOHRYYwAxKYwJYc0T4D12pDA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + } + }, + "get-stream": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", + "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "onetime": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "@cypress/listr-verbose-renderer": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz", + "integrity": "sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "cli-cursor": "^1.0.2", + "date-fns": "^1.27.2", + "figures": "^1.7.0" + }, + "dependencies": { + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "@cypress/request": { + "version": "2.88.5", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.5.tgz", + "integrity": "sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "@cypress/xvfb": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", + "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", + "dev": true, + "requires": { + "debug": "^3.1.0", + "lodash.once": "^4.1.1" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "@istanbuljs/load-nyc-config": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz", + "integrity": "sha512-ZR0rq/f/E4f4XcgnDvtMWXCUJpi8eO0rssVhmztsZqLIEFA9UUP9zmpE0VxlM+kv/E1ul2I876Fwil2ayptDVg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz", + "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", + "dev": true + }, + "@samverschueren/stream-to-observable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz", + "integrity": "sha512-MI4Xx6LHs4Webyvi6EbspgyAb4D2Q2VtnCQ1blOJcoLS6mVa8lNN2rkIy1CVxfTUpoyIbCTkXES1rLXztFD1lg==", + "dev": true, + "requires": { + "any-observable": "^0.3.0" + } + }, + "@types/blob-util": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/blob-util/-/blob-util-1.3.3.tgz", + "integrity": "sha512-4ahcL/QDnpjWA2Qs16ZMQif7HjGP2cw3AGjHabybjw7Vm1EKu+cfQN1D78BaZbS1WJNa1opSMF5HNMztx7lR0w==", + "dev": true + }, + "@types/bluebird": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.29.tgz", + "integrity": "sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==", + "dev": true + }, + "@types/chai": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.7.tgz", + "integrity": "sha512-luq8meHGYwvky0O7u0eQZdA7B4Wd9owUCqvbw2m3XCrCU8mplYOujMBbvyS547AxJkC+pGnd0Cm15eNxEUNU8g==", + "dev": true + }, + "@types/chai-jquery": { + "version": "1.1.40", + "resolved": "https://registry.npmjs.org/@types/chai-jquery/-/chai-jquery-1.1.40.tgz", + "integrity": "sha512-mCNEZ3GKP7T7kftKeIs7QmfZZQM7hslGSpYzKbOlR2a2HCFf9ph4nlMRA9UnuOETeOQYJVhJQK7MwGqNZVyUtQ==", + "dev": true, + "requires": { + "@types/chai": "*", + "@types/jquery": "*" + } + }, + "@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, + "@types/jquery": { + "version": "3.3.31", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.31.tgz", + "integrity": "sha512-Lz4BAJihoFw5nRzKvg4nawXPzutkv7wmfQ5121avptaSIXlDNJCUuxZxX/G+9EVidZGuO0UBlk+YjKbwRKJigg==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, + "@types/lodash": { + "version": "4.14.149", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", + "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==", + "dev": true + }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", + "dev": true + }, + "@types/mocha": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", + "dev": true + }, + "@types/sinon": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.5.1.tgz", + "integrity": "sha512-EZQUP3hSZQyTQRfiLqelC9NMWd1kqLcmQE0dMiklxBkgi84T+cHOhnKpgk4NnOWpGX863yE6+IaGnOXUNFqDnQ==", + "dev": true + }, + "@types/sinon-chai": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-3.2.3.tgz", + "integrity": "sha512-TOUFS6vqS0PVL1I8NGVSNcFaNJtFoyZPXZ5zur+qlhDfOmQECZZM4H4kKgca6O8L+QceX/ymODZASfUfn+y4yQ==", + "dev": true, + "requires": { + "@types/chai": "*", + "@types/sinon": "*" + } + }, + "@types/sizzle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", + "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==", + "dev": true + }, + "JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, + "acorn": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", + "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==", + "dev": true + }, + "acorn-node": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "dev": true, + "requires": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "acorn-walk": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.1.1.tgz", + "integrity": "sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==", + "dev": true + }, + "aggregate-error": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", + "integrity": "sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "dependencies": { + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + } + } + }, + "ajv": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.0.tgz", + "integrity": "sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "any-observable": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz", + "integrity": "sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==", + "dev": true + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + } + } + }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, + "arch": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.1.1.tgz", + "integrity": "sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg==", + "dev": true + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dev": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", + "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "async": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", + "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==", + "dev": true + }, + "async-each": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", + "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true + }, + "aws4": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz", + "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==", + "dev": true + }, + "babel-plugin-add-module-exports": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-1.0.2.tgz", + "integrity": "sha512-4paN7RivvU3Rzju1vGSHWPjO8Y0rI6droWvSFKI6dvEQ4mvoV0zGojnlzVRfI6N8zISo6VERXt3coIuVmzuvNg==", + "dev": true, + "requires": { + "chokidar": "^2.0.4" + } + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz", + "integrity": "sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, + "babelify": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/babelify/-/babelify-10.0.0.tgz", + "integrity": "sha512-X40FaxyH7t3X+JFAKvb1H9wooWKLRCi8pg3m8poqtdZaIng+bjzp9RvKQCvRjF9isHiPkXspbbXT/zwXLtwgwg==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "dev": true + }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browser-pack": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", + "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "combine-source-map": "~0.8.0", + "defined": "^1.0.0", + "safe-buffer": "^5.1.1", + "through2": "^2.0.0", + "umd": "^3.0.0" + } + }, + "browser-resolve": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.3.tgz", + "integrity": "sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ==", + "dev": true, + "requires": { + "resolve": "1.1.7" + }, + "dependencies": { + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + } + } + }, + "browserify": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/browserify/-/browserify-16.2.3.tgz", + "integrity": "sha512-zQt/Gd1+W+IY+h/xX2NYMW4orQWhqSwyV+xsblycTtpOuB27h1fZhhNQuipJ4t79ohw4P4mMem0jp/ZkISQtjQ==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "assert": "^1.4.0", + "browser-pack": "^6.0.1", + "browser-resolve": "^1.11.0", + "browserify-zlib": "~0.2.0", + "buffer": "^5.0.2", + "cached-path-relative": "^1.0.0", + "concat-stream": "^1.6.0", + "console-browserify": "^1.1.0", + "constants-browserify": "~1.0.0", + "crypto-browserify": "^3.0.0", + "defined": "^1.0.0", + "deps-sort": "^2.0.0", + "domain-browser": "^1.2.0", + "duplexer2": "~0.1.2", + "events": "^2.0.0", + "glob": "^7.1.0", + "has": "^1.0.0", + "htmlescape": "^1.1.0", + "https-browserify": "^1.0.0", + "inherits": "~2.0.1", + "insert-module-globals": "^7.0.0", + "labeled-stream-splicer": "^2.0.0", + "mkdirp": "^0.5.0", + "module-deps": "^6.0.0", + "os-browserify": "~0.3.0", + "parents": "^1.0.1", + "path-browserify": "~0.0.0", + "process": "~0.11.0", + "punycode": "^1.3.2", + "querystring-es3": "~0.2.0", + "read-only-stream": "^2.0.0", + "readable-stream": "^2.0.2", + "resolve": "^1.1.4", + "shasum": "^1.0.0", + "shell-quote": "^1.6.1", + "stream-browserify": "^2.0.0", + "stream-http": "^2.0.0", + "string_decoder": "^1.1.1", + "subarg": "^1.0.0", + "syntax-error": "^1.1.1", + "through2": "^2.0.0", + "timers-browserify": "^1.0.1", + "tty-browserify": "0.0.1", + "url": "~0.11.0", + "util": "~0.10.1", + "vm-browserify": "^1.0.0", + "xtend": "^4.0.0" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", + "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", + "dev": true, + "requires": { + "bn.js": "^4.1.1", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.2", + "elliptic": "^6.0.0", + "inherits": "^2.0.1", + "parse-asn1": "^5.0.0" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + } + }, + "browserslist": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.11.1.tgz", + "integrity": "sha512-DCTr3kDrKEYNw6Jb9HFxVLQNaue8z+0ZfRBRjmCunKDEXEBajKDj2Y+Uelg+Pi29OnvaSGwjOsnRyNEkXzHg5g==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001038", + "electron-to-chromium": "^1.3.390", + "node-releases": "^1.1.53", + "pkg-up": "^2.0.0" + } + }, + "buffer": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.5.0.tgz", + "integrity": "sha512-9FTEDjLjwoAkEwyMGDjYJQN2gfRgOKBKRfiglhvibGbpeeU/pQn1bJxQqm32OD/AIeEuHxU9roxXxg34Byp/Ww==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "dev": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "cached-path-relative": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.2.tgz", + "integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==", + "dev": true + }, + "cachedir": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", + "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==", + "dev": true + }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001041", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001041.tgz", + "integrity": "sha512-fqDtRCApddNrQuBxBS7kEiSGdBsgO4wiVw4G/IClfqzfhW45MbTumfN4cuUJGTM0YGFNn97DCXPJ683PS6zwvA==", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "check-more-types": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=", + "dev": true + }, + "chokidar": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", + "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + } + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, + "cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "dev": true, + "requires": { + "restore-cursor": "^1.0.1" + } + }, + "cli-table3": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.5.1.tgz", + "integrity": "sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw==", + "dev": true, + "requires": { + "colors": "^1.1.2", + "object-assign": "^4.1.0", + "string-width": "^2.1.1" + } + }, + "cli-truncate": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz", + "integrity": "sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ=", + "dev": true, + "requires": { + "slice-ansi": "0.0.4", + "string-width": "^1.0.1" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "coffeeify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/coffeeify/-/coffeeify-3.0.1.tgz", + "integrity": "sha512-Qjnr7UX6ldK1PHV7wCnv7AuCd4q19KTUtwJnu/6JRJB4rfm12zvcXtKdacUoePOKr1I4ka/ydKiwWpNAdsQb0g==", + "dev": true, + "requires": { + "convert-source-map": "^1.3.0", + "through2": "^2.0.0" + } + }, + "coffeescript": { + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-1.12.7.tgz", + "integrity": "sha512-pLXHFxQMPklVoEekowk8b3erNynC+DVJzChxS/LCBBgR6/8AJkHivkm//zbowcfc7BTCAjryuhx6gPqPRfsFoA==", + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "optional": true + }, + "combine-source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", + "integrity": "sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos=", + "dev": true, + "requires": { + "convert-source-map": "~1.1.0", + "inline-source-map": "~0.6.0", + "lodash.memoize": "~3.0.3", + "source-map": "~0.5.3" + }, + "dependencies": { + "convert-source-map": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", + "integrity": "sha1-SCnId+n+SbMWHzvzZziI4gRpmGA=", + "dev": true + } + } + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.0.tgz", + "integrity": "sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw==", + "dev": true + }, + "common-tags": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz", + "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "console-browserify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "core-js-compat": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.5.tgz", + "integrity": "sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng==", + "dev": true, + "requires": { + "browserslist": "^4.8.5", + "semver": "7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true + } + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "create-ecdh": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", + "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.0.0" + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "cypress": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-4.3.0.tgz", + "integrity": "sha512-xO1oef4ns4koDAkQROGJIhKKhGHDOKfOmlirwP1QAk9w/no+YJpN7HZ6IUPiXwWw3C7xVLjScoI8Dad0z5uTTg==", + "dev": true, + "requires": { + "@cypress/listr-verbose-renderer": "0.4.1", + "@cypress/request": "2.88.5", + "@cypress/xvfb": "1.2.4", + "@types/blob-util": "1.3.3", + "@types/bluebird": "3.5.29", + "@types/chai": "4.2.7", + "@types/chai-jquery": "1.1.40", + "@types/jquery": "3.3.31", + "@types/lodash": "4.14.149", + "@types/minimatch": "3.0.3", + "@types/mocha": "5.2.7", + "@types/sinon": "7.5.1", + "@types/sinon-chai": "3.2.3", + "@types/sizzle": "2.3.2", + "arch": "2.1.1", + "bluebird": "3.7.2", + "cachedir": "2.3.0", + "chalk": "2.4.2", + "check-more-types": "2.24.0", + "cli-table3": "0.5.1", + "commander": "4.1.0", + "common-tags": "1.8.0", + "debug": "4.1.1", + "eventemitter2": "4.1.2", + "execa": "1.0.0", + "executable": "4.1.1", + "extract-zip": "1.7.0", + "fs-extra": "8.1.0", + "getos": "3.1.4", + "is-ci": "2.0.0", + "is-installed-globally": "0.1.0", + "lazy-ass": "1.6.0", + "listr": "0.14.3", + "lodash": "4.17.15", + "log-symbols": "3.0.0", + "minimist": "1.2.5", + "moment": "2.24.0", + "ospath": "1.2.2", + "pretty-bytes": "5.3.0", + "ramda": "0.26.1", + "request-progress": "3.0.0", + "supports-color": "7.1.0", + "tmp": "0.1.0", + "untildify": "4.0.0", + "url": "0.11.0", + "yauzl": "2.10.0" + } + }, + "dash-ast": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dash-ast/-/dash-ast-1.0.0.tgz", + "integrity": "sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA==", + "dev": true + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "date-fns": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", + "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "default-require-extensions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", + "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", + "dev": true, + "requires": { + "strip-bom": "^4.0.0" + } + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", + "dev": true + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "deps-sort": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.1.tgz", + "integrity": "sha512-1orqXQr5po+3KI6kQb9A4jnXT1PBwggGl2d7Sq2xsnOeI9GPcE/tGcF9UiSZtZBM7MukY4cAh7MemS6tZYipfw==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "shasum-object": "^1.0.0", + "subarg": "^1.0.0", + "through2": "^2.0.0" + } + }, + "des.js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", + "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "detective": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", + "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", + "dev": true, + "requires": { + "acorn-node": "^1.6.1", + "defined": "^1.0.0", + "minimist": "^1.1.1" + } + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dev": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "electron-to-chromium": { + "version": "1.3.406", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.406.tgz", + "integrity": "sha512-bx8vBZoEbhsMmwEZIj2twzfhFoKKZlSLQiENhqgc5/FdAj4/UHEzAri42OTSFA5+0agLR03ReTvIms2dfZ7/Ew==", + "dev": true + }, "elegant-spinner": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", - "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=" + "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=", + "dev": true + }, + "elliptic": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", + "dev": true, + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint-plugin-cypress": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.10.3.tgz", + "integrity": "sha512-CvFeoCquShfO8gHNIKA1VpUTz78WtknMebLemBd1lRbcmJNjwpqCqpQYUG/XVja8GjdX/e2TJXYa+EUBxehtUg==", + "dev": true, + "requires": { + "globals": "^11.12.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "eventemitter2": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-4.1.2.tgz", + "integrity": "sha1-DhqEd6+CGm7zmVsxG/dMI6UkfxU=", + "dev": true + }, + "events": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/events/-/events-2.1.0.tgz", + "integrity": "sha512-3Zmiobend8P9DjmKAty0Era4jV8oJ0yGYe2nJJAxgymF9+N8F2m0hhZiMoWtcfepExzNKZumFU3ksdQbInGWCg==", + "dev": true + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "executable": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", + "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "dev": true, + "requires": { + "pify": "^2.2.0" + } + }, + "exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=", + "dev": true + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + "extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "dev": true, + "requires": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } }, - "execa": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.10.0.tgz", - "integrity": "sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==", + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", + "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-safe-stringify": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", + "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==", + "dev": true + }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "dev": true, "requires": { - "cross-spawn": "^6.0.0", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" + "pend": "~1.2.0" } }, - "executable": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "dev": true, "requires": { - "pify": "^2.2.0" + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" } }, - "exit-hook": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", - "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=" + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } }, - "extract-zip": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz", - "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=", + "find-cache-dir": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz", + "integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.2.tgz", + "integrity": "sha512-PD6G8QG3S4FK/XCGFbEQrDqO2AnMMsy0meR7lerlIOHAAbkuavGU/pOqprrlvfTNjvowivTeBsjebAL0NSoMxw==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fromentries": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.2.0.tgz", + "integrity": "sha512-33X7H/wdfO99GdRLLgkjUrD4geAFdq/Uv0kl3HD4da6HDixd2GUg8Mw7dahLCV9r/EARkmtYBB6Tch4EEokFTQ==", + "dev": true + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.12.tgz", + "integrity": "sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q==", + "dev": true, + "optional": true, "requires": { - "concat-stream": "1.6.2", - "debug": "2.6.9", - "mkdirp": "0.5.1", - "yauzl": "2.4.1" + "bindings": "^1.5.0", + "nan": "^2.12.1", + "node-pre-gyp": "*" }, "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.1.4", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "3.2.6", + "bundled": true, + "dev": true, + "optional": true, "requires": { - "ms": "2.0.0" + "ms": "^2.1.1" + } + }, + "deep-extend": { + "version": "0.6.0", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.6.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.24", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-walk": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "bundled": true, + "dev": true, + "optional": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true + }, + "minipass": { + "version": "2.9.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.3.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.9.0" + } + }, + "mkdirp": { + "version": "0.5.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimist": "^1.2.5" } }, "ms": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.3.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.14.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4.4.2" + } + }, + "nopt": { + "version": "4.0.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-normalize-package-bin": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.4.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1", + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.8", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "readable-stream": { + "version": "2.3.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.7.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.7.1", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.13", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.8.6", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + } }, - "yauzl": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", - "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=", + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "optional": true, "requires": { - "fd-slicer": "~1.0.1" + "string-width": "^1.0.2 || 2" } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "yallist": { + "version": "3.1.1", + "bundled": true, + "dev": true, + "optional": true } } }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" - }, - "fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" - }, - "fast-json-stable-stringify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", - "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" - }, - "fd-slicer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", - "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", - "requires": { - "pend": "~1.2.0" - } + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true }, - "figures": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", - "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", - "requires": { - "escape-string-regexp": "^1.0.5", - "object-assign": "^4.1.0" - } + "gensync": { + "version": "1.0.0-beta.1", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", + "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==", + "dev": true }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + "get-assigned-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz", + "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==", + "dev": true }, - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true }, - "fs-extra": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", - "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "requires": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "pump": "^3.0.0" } }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" - }, - "get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true }, "getos": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.1.1.tgz", - "integrity": "sha512-oUP1rnEhAr97rkitiszGP9EgDVYnmchgFzfqRzSkgtfv7ai6tEi7Ko8GgjNXts7VLWEqrTWyhsOKLe5C5b/Zkg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/getos/-/getos-3.1.4.tgz", + "integrity": "sha512-UORPzguEB/7UG5hqiZai8f0vQ7hzynMQyJLxStoQ8dPGAcmgsfXOPA4iE/fGtweHYkK+z4zc9V0g+CIFRf5HYw==", + "dev": true, "requires": { - "async": "2.6.1" + "async": "^3.1.0" } }, "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -523,6 +3842,7 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -532,37 +3852,78 @@ "path-is-absolute": "^1.0.0" } }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, "global-dirs": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", + "dev": true, "requires": { "ini": "^1.3.4" } }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, "graceful-fs": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==" + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", + "dev": true }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true }, "har-validator": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "dev": true, "requires": { "ajv": "^6.5.5", "har-schema": "^2.0.0" } }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, "has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, "requires": { "ansi-regex": "^2.0.0" } @@ -570,30 +3931,154 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hasha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.0.tgz", + "integrity": "sha512-2W+jKdQbAdSIrggA8Q35Br8qKadTrqCTC8+XZvBWepKDK6m9XkX6Iz1a2yh2KP01kzAR/dpuMeUnocoLYDcskw==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "dependencies": { + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + } + } + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dev": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "htmlescape": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", + "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=", + "dev": true }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "dev": true + }, + "human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, "indent-string": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", - "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", - "requires": { - "repeating": "^2.0.0" - } + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=", + "dev": true }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -602,116 +4087,548 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "ini": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true + }, + "inline-source-map": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", + "integrity": "sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU=", + "dev": true, + "requires": { + "source-map": "~0.5.3" + } + }, + "insert-module-globals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.2.0.tgz", + "integrity": "sha512-VE6NlW+WGn2/AeOMd496AHFYmE7eLKkUY6Ty31k4og5vmA3Fjuwe9v6ifH6Xx/Hz27QvdoMoviw1/pqWRB09Sw==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "acorn-node": "^1.5.2", + "combine-source-map": "^0.8.0", + "concat-stream": "^1.6.1", + "is-buffer": "^1.1.0", + "path-is-absolute": "^1.0.1", + "process": "~0.11.0", + "through2": "^2.0.0", + "undeclared-identifiers": "^1.1.2", + "xtend": "^4.0.0" + } + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "is-ci": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz", - "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, "requires": { - "ci-info": "^1.5.0" + "ci-info": "^2.0.0" } }, - "is-finite": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", - "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, "requires": { - "number-is-nan": "^1.0.0" + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } } }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, "requires": { - "number-is-nan": "^1.0.0" + "is-extglob": "^2.1.1" } }, "is-installed-globally": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz", "integrity": "sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=", + "dev": true, "requires": { "global-dirs": "^0.1.0", "is-path-inside": "^1.0.0" } }, - "is-path-inside": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", - "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-observable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz", + "integrity": "sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA==", + "dev": true, + "requires": { + "symbol-observable": "^1.1.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", + "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "requires": { + "append-transform": "^2.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.1.tgz", + "integrity": "sha512-imIchxnodll7pvQBYOqUu88EufLCU56LMeFPZZM/fJZ1irYcYdqroaV+ACK1Ila8ls09iEYArp+nqyC6lW1Vfg==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@babel/parser": "^7.7.5", + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "@babel/core": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.9.0.tgz", + "integrity": "sha512-kWc7L0fw1xwvI0zi8OKVBuxRVefwGOrKSQMvrQ3dW+bIIavBY3/NpXmpjMy7bQnLgwgzWQZ8TlM57YHpHNHz4w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.0", + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helpers": "^7.9.0", + "@babel/parser": "^7.9.0", + "@babel/template": "^7.8.6", + "@babel/traverse": "^7.9.0", + "@babel/types": "^7.9.0", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.13", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-processinfo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^3.3.3" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.2.tgz", + "integrity": "sha512-PD6G8QG3S4FK/XCGFbEQrDqO2AnMMsy0meR7lerlIOHAAbkuavGU/pOqprrlvfTNjvowivTeBsjebAL0NSoMxw==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz", + "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz", + "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==", + "dev": true, "requires": { - "path-is-inside": "^1.0.1" + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" } }, - "is-promise": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", - "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" - }, - "is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + "js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } }, "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true }, "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz", + "integrity": "sha1-YRwj6BTbN1Un34URk9tZ3Sryf0U=", + "dev": true, + "requires": { + "jsonify": "~0.0.0" + } }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } }, "jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, "requires": { "graceful-fs": "^4.1.6" } }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "dev": true + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -719,70 +4636,56 @@ "verror": "1.10.0" } }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "labeled-stream-splicer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", + "integrity": "sha512-Ca4LSXFFZUjPScRaqOcFxneA0VpKZr4MMYCljyQr4LIewTLb3Y0IUTIsnBBsVubIeEfxeSZpSjSsRM8APEQaAw==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "stream-splicer": "^2.0.0" + } + }, "lazy-ass": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=" + "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=", + "dev": true }, "listr": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/listr/-/listr-0.12.0.tgz", - "integrity": "sha1-a84sD1YD+klYDqF81qAMwOX6RRo=", + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", + "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", + "dev": true, "requires": { - "chalk": "^1.1.3", - "cli-truncate": "^0.2.1", - "figures": "^1.7.0", - "indent-string": "^2.1.0", + "@samverschueren/stream-to-observable": "^0.3.0", + "is-observable": "^1.1.0", "is-promise": "^2.1.0", "is-stream": "^1.1.0", "listr-silent-renderer": "^1.1.1", - "listr-update-renderer": "^0.2.0", - "listr-verbose-renderer": "^0.4.0", - "log-symbols": "^1.0.2", - "log-update": "^1.0.2", - "ora": "^0.2.3", - "p-map": "^1.1.1", - "rxjs": "^5.0.0-beta.11", - "stream-to-observable": "^0.1.0", - "strip-ansi": "^3.0.1" - }, - "dependencies": { - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "log-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", - "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", - "requires": { - "chalk": "^1.0.0" - } - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" - } + "listr-update-renderer": "^0.5.0", + "listr-verbose-renderer": "^0.5.0", + "p-map": "^2.0.0", + "rxjs": "^6.3.3" } }, "listr-silent-renderer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz", - "integrity": "sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4=" + "integrity": "sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4=", + "dev": true }, "listr-update-renderer": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.2.0.tgz", - "integrity": "sha1-yoDhd5tOcCZoB+ju0a1qvjmFUPk=", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz", + "integrity": "sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA==", + "dev": true, "requires": { "chalk": "^1.1.3", "cli-truncate": "^0.2.1", @@ -790,7 +4693,7 @@ "figures": "^1.7.0", "indent-string": "^3.0.0", "log-symbols": "^1.0.2", - "log-update": "^1.0.2", + "log-update": "^2.3.0", "strip-ansi": "^3.0.1" }, "dependencies": { @@ -798,6 +4701,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, "requires": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", @@ -806,15 +4710,11 @@ "supports-color": "^2.0.0" } }, - "indent-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", - "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=" - }, "log-symbols": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", + "dev": true, "requires": { "chalk": "^1.0.0" } @@ -822,132 +4722,417 @@ "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true } } }, "listr-verbose-renderer": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz", - "integrity": "sha1-ggb0z21S3cWCfl/RSYng6WWTOjU=", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz", + "integrity": "sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw==", + "dev": true, "requires": { - "chalk": "^1.1.3", - "cli-cursor": "^1.0.2", + "chalk": "^2.4.1", + "cli-cursor": "^2.1.0", "date-fns": "^1.27.2", - "figures": "^1.7.0" + "figures": "^2.0.0" }, "dependencies": { - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" + "restore-cursor": "^2.0.0" } }, - "supports-color": { + "figures": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } } } }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "dev": true + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", + "dev": true + }, + "lodash.memoize": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", + "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=", + "dev": true }, "lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", + "dev": true }, "log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, "requires": { - "chalk": "^2.0.1" + "chalk": "^2.4.2" } }, "log-update": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-1.0.2.tgz", - "integrity": "sha1-GZKfZMQJPS0ucHWh2tivWcKWuNE=", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-2.3.0.tgz", + "integrity": "sha1-iDKP19HOeTiykoN0bwsbwSayRwg=", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "cli-cursor": "^2.0.0", + "wrap-ansi": "^3.0.1" + }, + "dependencies": { + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + } + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "make-dir": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.2.tgz", + "integrity": "sha512-rYKABKutXa6vXTXhoV18cBE7PaewPXHe/Bdq4v+ZLMhxbWApkFFplT0LcbMW+6BbjnQXzZ/sAvSE/JdguApG5w==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, "requires": { - "ansi-escapes": "^1.0.0", - "cli-cursor": "^1.0.2" + "bn.js": "^4.0.0", + "brorand": "^1.0.1" } }, "mime-db": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.42.0.tgz", - "integrity": "sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==" + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", + "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==", + "dev": true }, "mime-types": { - "version": "2.1.25", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.25.tgz", - "integrity": "sha512-5KhStqB5xpTAeGqKBAMgwaYMnQik7teQN4IAzC7npDv6kzeU6prfkR67bc87J1kWMPGkoaZSq1npmexMgkmEVg==", + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", + "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", + "dev": true, "requires": { - "mime-db": "1.42.0" + "mime-db": "1.43.0" } }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "dev": true + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" - }, - "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, "requires": { - "minimist": "0.0.8" + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" }, "dependencies": { - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } } } }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "module-deps": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.2.tgz", + "integrity": "sha512-a9y6yDv5u5I4A+IPHTnqFxcaKr4p50/zxTjcQJaX2ws9tN/W6J6YXnEKhqRyPhl494dkcxx951onSKVezmI+3w==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "browser-resolve": "^1.7.0", + "cached-path-relative": "^1.0.2", + "concat-stream": "~1.6.0", + "defined": "^1.0.0", + "detective": "^5.2.0", + "duplexer2": "^0.1.2", + "inherits": "^2.0.1", + "parents": "^1.0.0", + "readable-stream": "^2.0.2", + "resolve": "^1.4.0", + "stream-combiner2": "^1.1.1", + "subarg": "^1.0.0", + "through2": "^2.0.0", + "xtend": "^4.0.0" + } + }, "moment": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", - "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==", + "dev": true }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", + "dev": true, + "optional": true }, "nanoid": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.7.tgz", "integrity": "sha512-fmS3qwDldm4bE01HCIRqNk+f255CNjnAoeV3Zzzv0KemObHKqYgirVaZA9DtKcjogicWjYcHkJs4D5A8CjnuVQ==" }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, + "node-releases": { + "version": "1.1.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.53.tgz", + "integrity": "sha512-wp8zyQVwef2hpZ/dJH7SfSrIPD6YoJz6BDQDpGEkcA0s3LpAQoxBIYmfIq6QAhC1DhwsyCgTaTTcONwX8qzCuQ==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true }, "npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, "requires": { "path-key": "^2.0.0" } @@ -955,22 +5140,196 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "nyc": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.0.1.tgz", + "integrity": "sha512-n0MBXYBYRqa67IVt62qW1r/d9UH/Qtr7SF1w/nQLJ9KxvWF6b2xCHImRAixHN9tnMMYHC2P14uo6KddNGwMgGg==", + "dev": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "requires": { "wrappy": "1" } @@ -978,117 +5337,393 @@ "onetime": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", - "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" - }, - "ora": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/ora/-/ora-0.2.3.tgz", - "integrity": "sha1-N1J9Igrc1Tw5tzVx11QVbV22V6Q=", - "requires": { - "chalk": "^1.1.1", - "cli-cursor": "^1.0.2", - "cli-spinners": "^0.1.2", - "object-assign": "^4.0.1" - }, - "dependencies": { - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" - } + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", + "dev": true + }, + "os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", + "dev": true + }, + "ospath": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", + "integrity": "sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=", + "dev": true + }, + "outpipe": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/outpipe/-/outpipe-1.1.1.tgz", + "integrity": "sha1-UM+GFjZeh+Ax4ppeyTOaPaRyX6I=", + "dev": true, + "requires": { + "shell-quote": "^1.4.2" } }, - "os-homedir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" - }, "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } }, "p-map": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz", - "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, + "parents": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", + "integrity": "sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E=", + "dev": true, + "requires": { + "path-platform": "~0.11.15" + } + }, + "parse-asn1": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.5.tgz", + "integrity": "sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ==", + "dev": true, + "requires": { + "asn1.js": "^4.0.0", + "browserify-aes": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3", + "safe-buffer": "^5.1.1" + } + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true }, "path-is-inside": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=" + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true }, "path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-platform": { + "version": "0.11.15", + "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", + "integrity": "sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I=", + "dev": true + }, + "pbkdf2": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", + "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==", + "dev": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } }, "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true }, "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } + }, + "pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-2.0.0.tgz", + "integrity": "sha1-yBmscoBZpGHKscOImivjxJoATX8=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "pretty-bytes": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.3.0.tgz", + "integrity": "sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg==", + "dev": true + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "requires": { + "fromentries": "^1.2.0" + } }, "psl": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.4.0.tgz", - "integrity": "sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw==" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", + "dev": true + }, + "public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "dev": true + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true }, "ramda": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.24.1.tgz", - "integrity": "sha1-w7d1UZfzW43DUCIoJixMkd22uFc=" + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz", + "integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==", + "dev": true + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "read-only-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", + "integrity": "sha1-JyT9aoET1zdkrCiNQ4YnDB2/F/A=", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } }, "readable-stream": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", - "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -1097,126 +5732,562 @@ "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } } }, - "repeating": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", - "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, "requires": { - "is-finite": "^1.0.0" + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" } }, - "request": { - "version": "2.88.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", - "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "regenerate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", + "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz", + "integrity": "sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==", + "dev": true, "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.0", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.4.3", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" + "regenerate": "^1.4.0" + } + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==", + "dev": true + }, + "regenerator-transform": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.4.tgz", + "integrity": "sha512-EaJaKPBI9GvKpvUz2mz4fhx7WPgvwRLY9v3hlNHWmAuJHI13T4nwKnNvm5RWJzEdnI5g5UwtOww+S8IdoUC2bw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4", + "private": "^0.1.8" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", + "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexpu-core": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz", + "integrity": "sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.2.0", + "regjsgen": "^0.5.1", + "regjsparser": "^0.6.4", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.2.0" + } + }, + "regjsgen": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.1.tgz", + "integrity": "sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg==", + "dev": true + }, + "regjsparser": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.4.tgz", + "integrity": "sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "requires": { + "es6-error": "^4.0.1" } }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, "request-progress": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", "integrity": "sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=", + "dev": true, "requires": { "throttleit": "^1.0.0" } }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "resolve": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", + "integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, "restore-cursor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", + "dev": true, "requires": { "exit-hook": "^1.0.0", "onetime": "^1.0.0" } }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "rxjs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", + "integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", + "dev": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shasum": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz", + "integrity": "sha1-5wEjENj0F/TetXEhUOVni4euVl8=", + "dev": true, + "requires": { + "json-stable-stringify": "~0.0.0", + "sha.js": "~2.4.4" + } + }, + "shasum-object": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shasum-object/-/shasum-object-1.0.0.tgz", + "integrity": "sha512-Iqo5rp/3xVi6M4YheapzZhhGPVs0yZwHj7wvwQ1B9z8H6zk+FEnI7y3Teq7qwnekfEhu8WmG2z0z4iWZaxLWVg==", + "dev": true, + "requires": { + "fast-safe-stringify": "^2.0.7" + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "shell-quote": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", + "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", + "dev": true + }, + "shortid": { + "version": "2.2.15", + "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz", + "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==", + "requires": { + "nanoid": "^2.1.0" + } + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "simple-concat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz", + "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=", + "dev": true + }, + "slice-ansi": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", + "dev": true + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, "requires": { - "glob": "^7.1.3" + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } } }, - "rxjs": { - "version": "5.5.12", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.12.tgz", - "integrity": "sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw==", + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, "requires": { - "symbol-observable": "1.0.1" + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } } }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "dev": true, "requires": { - "shebang-regex": "^1.0.0" + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" } }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true }, - "shortid": { - "version": "2.2.15", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.15.tgz", - "integrity": "sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw==", + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, "requires": { - "nanoid": "^2.1.0" + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "dependencies": { + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } } }, - "signal-exit": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", - "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } }, - "slice-ansi": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", - "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=" + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -1229,88 +6300,314 @@ "tweetnacl": "~0.14.0" } }, - "stream-to-observable": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/stream-to-observable/-/stream-to-observable-0.1.0.tgz", - "integrity": "sha1-Rb8dny19wJvtgfHDB8Qw5ouEz/4=" + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "stream-browserify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", + "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=", + "dev": true, + "requires": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "stream-splicer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.1.tgz", + "integrity": "sha512-Xizh4/NPuYSyAXyT7g8IvdJ9HJpxIGL9PjyhtywCZvvP0OPIdqyrr4dMikeuvY8xahpdKEBlBTySe583totajg==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.2" + } }, "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } } }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "requires": { "safe-buffer": "~5.1.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } } }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, "requires": { "ansi-regex": "^2.0.0" } }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "subarg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", + "integrity": "sha1-9izxdYHplrSPyWVpn1TAauJouNI=", + "dev": true, + "requires": { + "minimist": "^1.1.0" + } }, "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, "requires": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + } } }, "symbol-observable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz", - "integrity": "sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ=" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true + }, + "syntax-error": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", + "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", + "dev": true, + "requires": { + "acorn-node": "^1.2.0" + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } }, "throttleit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz", - "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=" + "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "timers-browserify": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", + "integrity": "sha1-ycWLV1voQHN1y14kYtrO50NZ9B0=", + "dev": true, + "requires": { + "process": "~0.11.0" + } }, "tmp": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz", "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==", + "dev": true, "requires": { "rimraf": "^2.6.3" } }, - "tough-cookie": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", - "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, "requires": { - "psl": "^1.1.24", - "punycode": "^1.4.1" + "kind-of": "^3.0.2" }, "dependencies": { - "punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } } } }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "tslib": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", + "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==", + "dev": true + }, + "tty-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", + "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", + "dev": true + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -1318,35 +6615,167 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "umd": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", + "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==", + "dev": true + }, + "undeclared-identifiers": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.3.tgz", + "integrity": "sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw==", + "dev": true, + "requires": { + "acorn-node": "^1.3.0", + "dash-ast": "^1.0.0", + "get-assigned-identifiers": "^1.2.0", + "simple-concat": "^1.0.0", + "xtend": "^4.0.1" + } + }, + "unicode-canonical-property-names-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", + "integrity": "sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz", + "integrity": "sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^1.0.4", + "unicode-property-aliases-ecmascript": "^1.0.4" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz", + "integrity": "sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz", + "integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==", + "dev": true + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } }, "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + } + } }, "untildify": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz", - "integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true + }, + "upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true }, "uri-js": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, "requires": { "punycode": "^2.1.0" } }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, "url": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "dev": true, "requires": { "punycode": "1.3.2", "querystring": "0.2.0" @@ -1355,60 +6784,270 @@ "punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", + "dev": true + } + } + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "requires": { + "inherits": "2.0.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true } } }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true }, "uuid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", - "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, + "vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "dev": true + }, + "watchify": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/watchify/-/watchify-3.11.1.tgz", + "integrity": "sha512-WwnUClyFNRMB2NIiHgJU9RQPQNqVeFk7OmZaWf5dC5EnNa0Mgr7imBydbaJ7tGTuPM2hz1Cb4uiBvK9NVxMfog==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "browserify": "^16.1.0", + "chokidar": "^2.1.1", + "defined": "^1.0.0", + "outpipe": "^1.1.0", + "through2": "^2.0.0", + "xtend": "^4.0.0" + } + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, "requires": { "isexe": "^2.0.0" } }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wrap-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz", + "integrity": "sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo=", + "dev": true, + "requires": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yargs": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz", + "integrity": "sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.1" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + } + } + }, + "yargs-parser": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.2.tgz", + "integrity": "sha512-hlIPNR3IzC1YuL1c2UwwDKpXlNFBqD1Fswwh1khz5+d8Cq/8yc/Mn0i+rQXduu8hcrFKvO7Eryk+09NecTQAAQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } }, "yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dev": true, "requires": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" - }, - "dependencies": { - "fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", - "requires": { - "pend": "~1.2.0" - } - } } } } diff --git a/superset-frontend/cypress-base/package.json b/superset-frontend/cypress-base/package.json index c6dddefc727b..ea8cc34ce148 100644 --- a/superset-frontend/cypress-base/package.json +++ b/superset-frontend/cypress-base/package.json @@ -9,7 +9,11 @@ "author": "Apcahe", "license": "Apache-2.0", "dependencies": { - "cypress": "^3.6.1", "shortid": "^2.2.15" + }, + "devDependencies": { + "@cypress/code-coverage": "^3.1.0", + "cypress": "4.3.0", + "eslint-plugin-cypress": "^2.10.3" } } diff --git a/superset-frontend/cypress_build.sh b/superset-frontend/cypress_build.sh index 80616e73f0ea..24ee884386dd 100755 --- a/superset-frontend/cypress_build.sh +++ b/superset-frontend/cypress_build.sh @@ -29,7 +29,7 @@ flask run -p 8081 --with-threads --reload --debugger & #block on the longer running javascript process time npm ci -time npm run build +time npm run build-instrumented echo "[completed js build steps]" #setup cypress diff --git a/superset-frontend/stylesheets/fonts/FiraCode/specimen.less b/superset-frontend/fonts/FiraCode/specimen.less similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/specimen.less rename to superset-frontend/fonts/FiraCode/specimen.less diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Bold.woff b/superset-frontend/fonts/FiraCode/woff/FiraCode-Bold.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Bold.woff rename to superset-frontend/fonts/FiraCode/woff/FiraCode-Bold.woff diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Light.woff b/superset-frontend/fonts/FiraCode/woff/FiraCode-Light.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Light.woff rename to superset-frontend/fonts/FiraCode/woff/FiraCode-Light.woff diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Medium.woff b/superset-frontend/fonts/FiraCode/woff/FiraCode-Medium.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Medium.woff rename to superset-frontend/fonts/FiraCode/woff/FiraCode-Medium.woff diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Regular.woff b/superset-frontend/fonts/FiraCode/woff/FiraCode-Regular.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Regular.woff rename to superset-frontend/fonts/FiraCode/woff/FiraCode-Regular.woff diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Retina.woff b/superset-frontend/fonts/FiraCode/woff/FiraCode-Retina.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-Retina.woff rename to superset-frontend/fonts/FiraCode/woff/FiraCode-Retina.woff diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-VF.woff b/superset-frontend/fonts/FiraCode/woff/FiraCode-VF.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff/FiraCode-VF.woff rename to superset-frontend/fonts/FiraCode/woff/FiraCode-VF.woff diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Bold.woff2 b/superset-frontend/fonts/FiraCode/woff2/FiraCode-Bold.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Bold.woff2 rename to superset-frontend/fonts/FiraCode/woff2/FiraCode-Bold.woff2 diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Light.woff2 b/superset-frontend/fonts/FiraCode/woff2/FiraCode-Light.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Light.woff2 rename to superset-frontend/fonts/FiraCode/woff2/FiraCode-Light.woff2 diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Medium.woff2 b/superset-frontend/fonts/FiraCode/woff2/FiraCode-Medium.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Medium.woff2 rename to superset-frontend/fonts/FiraCode/woff2/FiraCode-Medium.woff2 diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Regular.woff2 b/superset-frontend/fonts/FiraCode/woff2/FiraCode-Regular.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Regular.woff2 rename to superset-frontend/fonts/FiraCode/woff2/FiraCode-Regular.woff2 diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Retina.woff2 b/superset-frontend/fonts/FiraCode/woff2/FiraCode-Retina.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-Retina.woff2 rename to superset-frontend/fonts/FiraCode/woff2/FiraCode-Retina.woff2 diff --git a/superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-VF.woff2 b/superset-frontend/fonts/FiraCode/woff2/FiraCode-VF.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/FiraCode/woff2/FiraCode-VF.woff2 rename to superset-frontend/fonts/FiraCode/woff2/FiraCode-VF.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Black.woff b/superset-frontend/fonts/InterUI/Inter-Black.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Black.woff rename to superset-frontend/fonts/InterUI/Inter-Black.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Black.woff2 b/superset-frontend/fonts/InterUI/Inter-Black.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Black.woff2 rename to superset-frontend/fonts/InterUI/Inter-Black.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-BlackItalic.woff b/superset-frontend/fonts/InterUI/Inter-BlackItalic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-BlackItalic.woff rename to superset-frontend/fonts/InterUI/Inter-BlackItalic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-BlackItalic.woff2 b/superset-frontend/fonts/InterUI/Inter-BlackItalic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-BlackItalic.woff2 rename to superset-frontend/fonts/InterUI/Inter-BlackItalic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Bold.woff b/superset-frontend/fonts/InterUI/Inter-Bold.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Bold.woff rename to superset-frontend/fonts/InterUI/Inter-Bold.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Bold.woff2 b/superset-frontend/fonts/InterUI/Inter-Bold.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Bold.woff2 rename to superset-frontend/fonts/InterUI/Inter-Bold.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-BoldItalic.woff b/superset-frontend/fonts/InterUI/Inter-BoldItalic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-BoldItalic.woff rename to superset-frontend/fonts/InterUI/Inter-BoldItalic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-BoldItalic.woff2 b/superset-frontend/fonts/InterUI/Inter-BoldItalic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-BoldItalic.woff2 rename to superset-frontend/fonts/InterUI/Inter-BoldItalic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraBold.woff b/superset-frontend/fonts/InterUI/Inter-ExtraBold.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraBold.woff rename to superset-frontend/fonts/InterUI/Inter-ExtraBold.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraBold.woff2 b/superset-frontend/fonts/InterUI/Inter-ExtraBold.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraBold.woff2 rename to superset-frontend/fonts/InterUI/Inter-ExtraBold.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraBoldItalic.woff b/superset-frontend/fonts/InterUI/Inter-ExtraBoldItalic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraBoldItalic.woff rename to superset-frontend/fonts/InterUI/Inter-ExtraBoldItalic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraBoldItalic.woff2 b/superset-frontend/fonts/InterUI/Inter-ExtraBoldItalic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraBoldItalic.woff2 rename to superset-frontend/fonts/InterUI/Inter-ExtraBoldItalic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraLight.woff b/superset-frontend/fonts/InterUI/Inter-ExtraLight.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraLight.woff rename to superset-frontend/fonts/InterUI/Inter-ExtraLight.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraLight.woff2 b/superset-frontend/fonts/InterUI/Inter-ExtraLight.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraLight.woff2 rename to superset-frontend/fonts/InterUI/Inter-ExtraLight.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraLightItalic.woff b/superset-frontend/fonts/InterUI/Inter-ExtraLightItalic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraLightItalic.woff rename to superset-frontend/fonts/InterUI/Inter-ExtraLightItalic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraLightItalic.woff2 b/superset-frontend/fonts/InterUI/Inter-ExtraLightItalic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ExtraLightItalic.woff2 rename to superset-frontend/fonts/InterUI/Inter-ExtraLightItalic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Italic.woff b/superset-frontend/fonts/InterUI/Inter-Italic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Italic.woff rename to superset-frontend/fonts/InterUI/Inter-Italic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Italic.woff2 b/superset-frontend/fonts/InterUI/Inter-Italic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Italic.woff2 rename to superset-frontend/fonts/InterUI/Inter-Italic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Light.woff b/superset-frontend/fonts/InterUI/Inter-Light.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Light.woff rename to superset-frontend/fonts/InterUI/Inter-Light.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Light.woff2 b/superset-frontend/fonts/InterUI/Inter-Light.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Light.woff2 rename to superset-frontend/fonts/InterUI/Inter-Light.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-LightItalic.woff b/superset-frontend/fonts/InterUI/Inter-LightItalic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-LightItalic.woff rename to superset-frontend/fonts/InterUI/Inter-LightItalic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-LightItalic.woff2 b/superset-frontend/fonts/InterUI/Inter-LightItalic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-LightItalic.woff2 rename to superset-frontend/fonts/InterUI/Inter-LightItalic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Medium.woff b/superset-frontend/fonts/InterUI/Inter-Medium.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Medium.woff rename to superset-frontend/fonts/InterUI/Inter-Medium.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Medium.woff2 b/superset-frontend/fonts/InterUI/Inter-Medium.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Medium.woff2 rename to superset-frontend/fonts/InterUI/Inter-Medium.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-MediumItalic.woff b/superset-frontend/fonts/InterUI/Inter-MediumItalic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-MediumItalic.woff rename to superset-frontend/fonts/InterUI/Inter-MediumItalic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-MediumItalic.woff2 b/superset-frontend/fonts/InterUI/Inter-MediumItalic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-MediumItalic.woff2 rename to superset-frontend/fonts/InterUI/Inter-MediumItalic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Regular.woff b/superset-frontend/fonts/InterUI/Inter-Regular.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Regular.woff rename to superset-frontend/fonts/InterUI/Inter-Regular.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Regular.woff2 b/superset-frontend/fonts/InterUI/Inter-Regular.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Regular.woff2 rename to superset-frontend/fonts/InterUI/Inter-Regular.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-SemiBold.woff b/superset-frontend/fonts/InterUI/Inter-SemiBold.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-SemiBold.woff rename to superset-frontend/fonts/InterUI/Inter-SemiBold.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-SemiBold.woff2 b/superset-frontend/fonts/InterUI/Inter-SemiBold.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-SemiBold.woff2 rename to superset-frontend/fonts/InterUI/Inter-SemiBold.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-SemiBoldItalic.woff b/superset-frontend/fonts/InterUI/Inter-SemiBoldItalic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-SemiBoldItalic.woff rename to superset-frontend/fonts/InterUI/Inter-SemiBoldItalic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-SemiBoldItalic.woff2 b/superset-frontend/fonts/InterUI/Inter-SemiBoldItalic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-SemiBoldItalic.woff2 rename to superset-frontend/fonts/InterUI/Inter-SemiBoldItalic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Thin.woff b/superset-frontend/fonts/InterUI/Inter-Thin.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Thin.woff rename to superset-frontend/fonts/InterUI/Inter-Thin.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-Thin.woff2 b/superset-frontend/fonts/InterUI/Inter-Thin.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-Thin.woff2 rename to superset-frontend/fonts/InterUI/Inter-Thin.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ThinItalic.woff b/superset-frontend/fonts/InterUI/Inter-ThinItalic.woff similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ThinItalic.woff rename to superset-frontend/fonts/InterUI/Inter-ThinItalic.woff diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-ThinItalic.woff2 b/superset-frontend/fonts/InterUI/Inter-ThinItalic.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-ThinItalic.woff2 rename to superset-frontend/fonts/InterUI/Inter-ThinItalic.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-italic.var.woff2 b/superset-frontend/fonts/InterUI/Inter-italic.var.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-italic.var.woff2 rename to superset-frontend/fonts/InterUI/Inter-italic.var.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter-roman.var.woff2 b/superset-frontend/fonts/InterUI/Inter-roman.var.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter-roman.var.woff2 rename to superset-frontend/fonts/InterUI/Inter-roman.var.woff2 diff --git a/superset-frontend/stylesheets/fonts/InterUI/Inter.var.woff2 b/superset-frontend/fonts/InterUI/Inter.var.woff2 similarity index 100% rename from superset-frontend/stylesheets/fonts/InterUI/Inter.var.woff2 rename to superset-frontend/fonts/InterUI/Inter.var.woff2 diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index 8a3c08a7ed43..44f460d4fcff 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -34,13 +34,10 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], globals: { 'ts-jest': { + babelConfig: true, diagnostics: { warnOnly: true, }, - tsConfig: { - jsx: 'react', - esModuleInterop: true, - }, }, }, }; diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index dc63e0d3c2e8..0834b7516fb3 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -5360,6 +5360,145 @@ "prop-types": "^15.6.0" } }, + "@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "requires": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + }, + "@emotion/core": { + "version": "10.0.28", + "resolved": "https://registry.npmjs.org/@emotion/core/-/core-10.0.28.tgz", + "integrity": "sha512-pH8UueKYO5jgg0Iq+AmCLxBsvuGtvlmiDCOuv8fGNYn3cowFpLN98L8zO56U0H1PjDIyAlXymgL3Wu7u7v6hbA==", + "requires": { + "@babel/runtime": "^7.5.5", + "@emotion/cache": "^10.0.27", + "@emotion/css": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", + "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + } + } + }, + "@emotion/css": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-10.0.27.tgz", + "integrity": "sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==", + "requires": { + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3", + "babel-plugin-emotion": "^10.0.27" + } + }, + "@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "requires": { + "@emotion/memoize": "0.7.4" + } + }, + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==" + }, + "@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "requires": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "@emotion/sheet": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz", + "integrity": "sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==" + }, + "@emotion/styled": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-10.0.27.tgz", + "integrity": "sha512-iK/8Sh7+NLJzyp9a5+vIQIXTYxfT4yB/OJbjzQanB2RZpvmzBQOHZWhpAMZWYEKRNNbsD6WfBw5sVWkb6WzS/Q==", + "requires": { + "@emotion/styled-base": "^10.0.27", + "babel-plugin-emotion": "^10.0.27" + } + }, + "@emotion/styled-base": { + "version": "10.0.31", + "resolved": "https://registry.npmjs.org/@emotion/styled-base/-/styled-base-10.0.31.tgz", + "integrity": "sha512-wTOE1NcXmqMWlyrtwdkqg87Mu6Rj1MaukEoEmEkHirO5IoHDJ8LgCQL4MjJODgxWxXibGR3opGp1p7YvkNEdXQ==", + "requires": { + "@babel/runtime": "^7.5.5", + "@emotion/is-prop-valid": "0.8.8", + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", + "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + } + } + }, + "@emotion/stylis": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==" + }, + "@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, + "@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==" + }, + "@emotion/weak-memoize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", + "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" + }, "@hot-loader/react-dom": { "version": "16.13.0", "resolved": "https://registry.npmjs.org/@hot-loader/react-dom/-/react-dom-16.13.0.tgz", @@ -5445,6 +5584,15 @@ } } }, + "@istanbuljs/nyc-config-typescript": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-1.0.1.tgz", + "integrity": "sha512-/gz6LgVpky205LuoOfwEZmnUtaSmdk0QIMcNFj9OvxhiMhPpKftMgZmGN7jNj7jR+lr8IB1Yks3QSSSNSxfoaQ==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2" + } + }, "@istanbuljs/schema": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz", @@ -7655,6 +7803,11 @@ "@babel/runtime": "^7.0.0" } }, + "@scarf/scarf": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-0.1.5.tgz", + "integrity": "sha512-Fx6atDc7JM1r0WkPCDhNetVZNp+DO21q/HGlomAKBG+k8vb1B8fg8Yige4oCf1P9OWTZWm5tM5i3jlXhrSbNOg==" + }, "@sinonjs/commons": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz", @@ -7765,11 +7918,12 @@ } }, "@superset-ui/connection": { - "version": "0.12.8", - "resolved": "https://registry.npmjs.org/@superset-ui/connection/-/connection-0.12.8.tgz", - "integrity": "sha512-08d+34LZAD7vEF6PjN4qgeJFpWGlE/wKakI2Uh+aCE78d9q+b65K4pGPu6w+NRVAIcjZRXBbgvD6+Wnr1vn/Tw==", + "version": "0.12.22", + "resolved": "https://registry.npmjs.org/@superset-ui/connection/-/connection-0.12.22.tgz", + "integrity": "sha512-m+48Nfl+d9VltAhkj71jH2cGReck2fSUNLMT7b9yPdsogCgRg8GzTzCClREEyEZ9qWcsGKZTTP4g1QCWjeGOpg==", "requires": { "@babel/runtime": "^7.1.2", + "fetch-retry": "^3.1.0", "whatwg-fetch": "^3.0.0" } }, @@ -8074,12 +8228,23 @@ } }, "@superset-ui/legacy-plugin-chart-table": { - "version": "0.11.20", - "resolved": "https://registry.npmjs.org/@superset-ui/legacy-plugin-chart-table/-/legacy-plugin-chart-table-0.11.20.tgz", - "integrity": "sha512-PEBO8ww07/7gXj9JB2pe0A2MsH5WmL5AiV++6Md7aZB4lqViKDf+KeS8sFnaoECmoxB50ADS3jBuwsXF/0NBVA==", + "version": "0.12.14", + "resolved": "https://registry.npmjs.org/@superset-ui/legacy-plugin-chart-table/-/legacy-plugin-chart-table-0.12.14.tgz", + "integrity": "sha512-PJeJnlH7SkXKuh7BBIBh66t5TumaiLvkPJJC2RJIHa9VQ06BHs8Jzad3XCrprBMy+pqTAItNGjo99hc/jv6KXg==", "requires": { + "@types/react-dom": "^16.9.6", "datatables.net-bs": "^1.10.20", "xss": "^1.0.6" + }, + "dependencies": { + "@types/react-dom": { + "version": "16.9.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.6.tgz", + "integrity": "sha512-S6ihtlPMDotrlCJE9ST1fRmYrQNNwfgL61UB4I1W7M6kPulUKx9fXAleW5zpdIjUQ4fTaaog8uERezjsGUj9HQ==", + "requires": { + "@types/react": "*" + } + } } }, "@superset-ui/legacy-plugin-chart-treemap": { @@ -8113,13 +8278,14 @@ } }, "@superset-ui/legacy-preset-chart-big-number": { - "version": "0.11.15", - "resolved": "https://registry.npmjs.org/@superset-ui/legacy-preset-chart-big-number/-/legacy-preset-chart-big-number-0.11.15.tgz", - "integrity": "sha512-Nd1ezgdzBfHIxUlGbYZ8Je3rklHvlygq1LE2dYGCKuOQkSDSpGLHU9Y9CMDOkurnmC46hMIxNe3DkVtY7xzTNQ==", + "version": "0.12.13", + "resolved": "https://registry.npmjs.org/@superset-ui/legacy-preset-chart-big-number/-/legacy-preset-chart-big-number-0.12.13.tgz", + "integrity": "sha512-4TQzN702nyTL6tHgoa93HMcrUvKKqlE3Tm7Gxaa0uQEi+BKPuP77u957oZzZwJBR0SY81A6ASk0ztN90gpVsQg==", "requires": { "@data-ui/xy-chart": "^0.0.84", + "@types/d3-color": "^1.2.2", + "@types/shortid": "^0.0.29", "d3-color": "^1.2.3", - "prop-types": "^15.6.2", "shortid": "^2.2.14" } }, @@ -8453,6 +8619,11 @@ "jed": "^1.1.1" } }, + "@superset-ui/validator": { + "version": "0.12.13", + "resolved": "https://registry.npmjs.org/@superset-ui/validator/-/validator-0.12.13.tgz", + "integrity": "sha512-X6GyXP80uJOhHrSUfS5Zf+jhFCLgiil9Md3YuNwArQN2qDK7qBsgb7vfiAhxPfKQfNgvmFeO5SVPZqdcl53aTw==" + }, "@types/airbnb-prop-types": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/@types/airbnb-prop-types/-/airbnb-prop-types-2.13.1.tgz", @@ -8540,6 +8711,11 @@ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-2.0.0.tgz", "integrity": "sha512-rGqfPVowNDTszSFvwoZIXvrPG7s/qKzm9piCRIH6xwTTRu7pPZ3ootULFnPkTt74B6i5lN0FpLQL24qGOw1uZA==" }, + "@types/d3-color": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.2.2.tgz", + "integrity": "sha512-6pBxzJ8ZP3dYEQ4YjQ+NVbQaOflfgXq/JbDiS99oLobM2o72uAST4q6yPxHv6FOTCRC/n35ktuo8pvw/S4M7sw==" + }, "@types/d3-format": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.3.1.tgz", @@ -8704,6 +8880,11 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.15.tgz", "integrity": "sha512-9kROxduaN98QghwwHmxXO2Xz3MaWf+I1sLVAA6KJDF5xix+IyXVhds0MAfdNwtcpSrzhaTsNB0/jnL86fgUhqA==" }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + }, "@types/prop-types": { "version": "15.5.8", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.8.tgz", @@ -8789,14 +8970,11 @@ } }, "@types/react-select": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.0.10.tgz", - "integrity": "sha512-oUHXqvbkRhC07q5JjeY6hE+NUqgUM6CyaRXEKYPvMCBqUOuLnYltyhiNx6Jpb+iFpYtNHSQtF4dNJfMdMooKoQ==", - "dev": true, + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-1.3.4.tgz", + "integrity": "sha512-0BwjswNzKBszG5O4xq72W54NrrbmOZvJfaM/Dwru3F6DhvFO9nihMP1IRzXSOJ1qGRCS3VCu9FnBYJ+25lSldw==", "requires": { - "@types/react": "*", - "@types/react-dom": "*", - "@types/react-transition-group": "*" + "@types/react": "*" } }, "@types/react-table": { @@ -8808,10 +8986,10 @@ "@types/react": "*" } }, - "@types/react-transition-group": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.2.4.tgz", - "integrity": "sha512-8DMUaDqh0S70TjkqU0DxOu80tFUiiaS9rxkWip/nb7gtvAsbqOXm02UCmR8zdcjWujgeYPiPNTVpVpKzUDotwA==", + "@types/react-ultimate-pagination": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/react-ultimate-pagination/-/react-ultimate-pagination-1.2.0.tgz", + "integrity": "sha512-xFyJn6Jl26Q0bi+QTnLo4W5tCDKOGNU5Gn9iCg+Y6J+VqtuKuJ1wcP1Ax+nXAu5HF9qTgApI/hRn7ceCDC6TAA==", "dev": true, "requires": { "@types/react": "*" @@ -8834,6 +9012,16 @@ "@types/react": "*" } }, + "@types/rison": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/rison/-/rison-0.0.6.tgz", + "integrity": "sha512-mE3eRK0fpTN/GnNBOIg2tGq2cFhchQXF6fCbrLxus75TgnoOECbdHikr948FGO/UAml7/ZhLMa5FbGkF5PKvmw==" + }, + "@types/shortid": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz", + "integrity": "sha1-gJPuBBam4r8qpjOBCRFLP7/6Dps=" + }, "@types/sizzle": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", @@ -10665,6 +10853,23 @@ "object.assign": "^4.1.0" } }, + "babel-plugin-emotion": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-10.0.29.tgz", + "integrity": "sha512-7Jpi1OCxjyz0k163lKtqP+LHMg5z3S6A7vMBfHnF06l2unmtsOmFDzZBpGf0CWo1G4m8UACfVcDJiSiRuu/cSw==", + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/serialize": "^0.11.16", + "babel-plugin-macros": "^2.0.0", + "babel-plugin-syntax-jsx": "^6.18.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^1.0.5", + "find-root": "^1.1.0", + "source-map": "^0.5.7" + } + }, "babel-plugin-istanbul": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz", @@ -10698,6 +10903,81 @@ "require-package-name": "^2.0.1" } }, + "babel-plugin-macros": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", + "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", + "requires": { + "@babel/runtime": "^7.7.2", + "cosmiconfig": "^6.0.0", + "resolve": "^1.12.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", + "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + } + }, + "import-fresh": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", + "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "parse-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", + "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1", + "lines-and-columns": "^1.1.6" + } + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + }, + "resolve": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", + "integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==", + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, + "babel-plugin-syntax-jsx": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", + "integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=" + }, "babel-polyfill": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz", @@ -10815,7 +11095,8 @@ }, "kind-of": { "version": "6.0.2", - "resolved": "" + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" } } }, @@ -11491,28 +11772,28 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, "optional": true }, "aproba": { "version": "1.2.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "dev": true, "optional": true, @@ -11523,14 +11804,14 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true, "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": false, + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "optional": true, @@ -11541,42 +11822,42 @@ }, "chownr": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", "dev": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true, "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true, "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true, "optional": true }, "debug": { "version": "4.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "dev": true, "optional": true, @@ -11586,28 +11867,28 @@ }, "deep-extend": { "version": "0.6.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "dev": true, "optional": true }, "fs-minipass": { "version": "1.2.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", "dev": true, "optional": true, @@ -11617,14 +11898,14 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, @@ -11641,7 +11922,7 @@ }, "glob": { "version": "7.1.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "optional": true, @@ -11656,14 +11937,14 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "iconv-lite": { "version": "0.4.24", - "resolved": false, + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "optional": true, @@ -11673,7 +11954,7 @@ }, "ignore-walk": { "version": "3.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "dev": true, "optional": true, @@ -11683,7 +11964,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "optional": true, @@ -11694,21 +11975,21 @@ }, "inherits": { "version": "2.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true, "optional": true }, "ini": { "version": "1.3.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "optional": true, @@ -11718,14 +11999,14 @@ }, "isarray": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "optional": true, @@ -11735,14 +12016,14 @@ }, "minimist": { "version": "0.0.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true, "optional": true }, "minipass": { "version": "2.3.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "dev": true, "optional": true, @@ -11753,7 +12034,7 @@ }, "minizlib": { "version": "1.2.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", "dev": true, "optional": true, @@ -11763,7 +12044,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "optional": true, @@ -11773,14 +12054,14 @@ }, "ms": { "version": "2.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true, "optional": true }, "needle": { "version": "2.3.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.0.tgz", "integrity": "sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==", "dev": true, "optional": true, @@ -11792,7 +12073,7 @@ }, "node-pre-gyp": { "version": "0.12.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz", "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "dev": true, "optional": true, @@ -11811,7 +12092,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -11822,14 +12103,14 @@ }, "npm-bundled": { "version": "1.0.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz", "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", "dev": true, "optional": true }, "npm-packlist": { "version": "1.4.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.1.tgz", "integrity": "sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==", "dev": true, "optional": true, @@ -11840,7 +12121,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "dev": true, "optional": true, @@ -11853,21 +12134,21 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "optional": true, @@ -11877,21 +12158,21 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "dev": true, "optional": true, @@ -11902,21 +12183,21 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true, "optional": true }, "rc": { "version": "1.2.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, "optional": true, @@ -11929,7 +12210,8 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true } @@ -11937,7 +12219,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "optional": true, @@ -11953,7 +12235,7 @@ }, "rimraf": { "version": "2.6.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, "optional": true, @@ -11963,49 +12245,49 @@ }, "safe-buffer": { "version": "5.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true, "optional": true }, "semver": { "version": "5.7.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "optional": true, @@ -12017,7 +12299,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "optional": true, @@ -12027,7 +12309,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "optional": true, @@ -12037,14 +12319,14 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "tar": { "version": "4.4.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz", "integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==", "dev": true, "optional": true, @@ -12060,14 +12342,14 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true, "optional": true }, "wide-align": { "version": "1.1.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "dev": true, "optional": true, @@ -12077,14 +12359,14 @@ }, "wrappy": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true, "optional": true }, "yallist": { "version": "3.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", "dev": true, "optional": true @@ -14382,6 +14664,81 @@ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=" }, + "emotion-theming": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/emotion-theming/-/emotion-theming-10.0.27.tgz", + "integrity": "sha512-MlF1yu/gYh8u+sLUqA0YuA9JX0P4Hb69WlKc/9OLo+WCXuX6sy/KoIa+qJimgmr2dWqnypYKYPX37esjDBbhdw==", + "requires": { + "@babel/runtime": "^7.5.5", + "@emotion/weak-memoize": "0.2.5", + "hoist-non-react-statics": "^3.3.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", + "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + } + } + }, + "emotion-ts-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/emotion-ts-plugin/-/emotion-ts-plugin-0.5.3.tgz", + "integrity": "sha512-GK60jpSpQIH3RdOeG9+/cnbdMJLpto8zVYVBMsKaFGL8yK2Pm/ej1Pa3LgkeBxVww/ELQyvTjwCMi5R+ZQkNSA==", + "dev": true, + "requires": { + "@emotion/hash": "^0.7.3", + "convert-source-map": "^1.7.0", + "find-root": "^1.1.0", + "lodash": "^4.17.15", + "source-map": "^0.7.3", + "tslib": "^1.10.0" + }, + "dependencies": { + "@emotion/hash": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.7.4.tgz", + "integrity": "sha512-fxfMSBMX3tlIbKUdtGKxqB1fyrH6gVrX39Gsv3y8lRYKUqlgDt3UMqQyGnR1bQMa2B8aGnhLZokZgg8vT0Le+A==", + "dev": true + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -14545,7 +14902,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "requires": { "is-arrayish": "^0.2.1" } @@ -14572,6 +14928,11 @@ "is-symbol": "^1.0.2" } }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "es6bindall": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/es6bindall/-/es6bindall-0.0.9.tgz", @@ -15982,7 +16343,8 @@ }, "kind-of": { "version": "6.0.2", - "resolved": "" + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" } } }, @@ -16112,6 +16474,14 @@ } } }, + "fetch-retry": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-3.1.0.tgz", + "integrity": "sha512-pHCYCq7g854KkebphR3tKb4M7TJK91ZI0K2BU82cWv+vNkFQn0PZZFrQd/mL+Ra/mj2HLZNvzkTRjPEq2Dh/Bg==", + "requires": { + "es6-promise": "^4.2.8" + } + }, "figgy-pudding": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz", @@ -16190,8 +16560,7 @@ "find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "find-up": { "version": "2.1.0", @@ -16414,28 +16783,28 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, "optional": true }, "aproba": { "version": "1.2.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "dev": true, "optional": true, @@ -16446,14 +16815,14 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true, "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": false, + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "optional": true, @@ -16464,42 +16833,42 @@ }, "chownr": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", "dev": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true, "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true, "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true, "optional": true }, "debug": { "version": "4.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "dev": true, "optional": true, @@ -16509,28 +16878,28 @@ }, "deep-extend": { "version": "0.6.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "dev": true, "optional": true }, "fs-minipass": { "version": "1.2.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", "dev": true, "optional": true, @@ -16540,14 +16909,14 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, @@ -16564,7 +16933,7 @@ }, "glob": { "version": "7.1.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "optional": true, @@ -16579,14 +16948,14 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "iconv-lite": { "version": "0.4.24", - "resolved": false, + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "optional": true, @@ -16596,7 +16965,7 @@ }, "ignore-walk": { "version": "3.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "dev": true, "optional": true, @@ -16606,7 +16975,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "optional": true, @@ -16617,21 +16986,21 @@ }, "inherits": { "version": "2.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true, "optional": true }, "ini": { "version": "1.3.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "optional": true, @@ -16641,14 +17010,14 @@ }, "isarray": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "optional": true, @@ -16658,14 +17027,14 @@ }, "minimist": { "version": "0.0.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true, "optional": true }, "minipass": { "version": "2.3.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "dev": true, "optional": true, @@ -16676,7 +17045,7 @@ }, "minizlib": { "version": "1.2.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", "dev": true, "optional": true, @@ -16686,7 +17055,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "optional": true, @@ -16696,14 +17065,14 @@ }, "ms": { "version": "2.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true, "optional": true }, "needle": { "version": "2.3.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.0.tgz", "integrity": "sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==", "dev": true, "optional": true, @@ -16715,7 +17084,7 @@ }, "node-pre-gyp": { "version": "0.12.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz", "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "dev": true, "optional": true, @@ -16734,7 +17103,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -16745,14 +17114,14 @@ }, "npm-bundled": { "version": "1.0.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz", "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", "dev": true, "optional": true }, "npm-packlist": { "version": "1.4.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.1.tgz", "integrity": "sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==", "dev": true, "optional": true, @@ -16763,7 +17132,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "dev": true, "optional": true, @@ -16776,21 +17145,21 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "optional": true, @@ -16800,21 +17169,21 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "dev": true, "optional": true, @@ -16825,21 +17194,21 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true, "optional": true }, "rc": { "version": "1.2.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, "optional": true, @@ -16852,7 +17221,8 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true } @@ -16860,7 +17230,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "optional": true, @@ -16876,7 +17246,7 @@ }, "rimraf": { "version": "2.6.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, "optional": true, @@ -16886,49 +17256,49 @@ }, "safe-buffer": { "version": "5.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true, "optional": true }, "semver": { "version": "5.7.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "optional": true, @@ -16940,7 +17310,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "optional": true, @@ -16950,7 +17320,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "optional": true, @@ -16960,14 +17330,14 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "tar": { "version": "4.4.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz", "integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==", "dev": true, "optional": true, @@ -16983,14 +17353,14 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true, "optional": true }, "wide-align": { "version": "1.1.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "dev": true, "optional": true, @@ -17000,14 +17370,14 @@ }, "wrappy": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true, "optional": true }, "yallist": { "version": "3.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", "dev": true, "optional": true @@ -17367,6 +17737,17 @@ "readable-stream": "^2.0.0" } }, + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "fs-readdir-recursive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", @@ -18926,8 +19307,7 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", - "dev": true + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" }, "is-binary-path": { "version": "1.0.1", @@ -25643,8 +26023,7 @@ "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" }, "json-schema": { "version": "0.2.3", @@ -25683,6 +26062,15 @@ "resolved": "http://registry.npmjs.org/json5/-/json5-0.5.1.tgz", "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -25827,6 +26215,11 @@ "type-check": "~0.3.2" } }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" + }, "loader-runner": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.3.1.tgz", @@ -25901,12 +26294,6 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" }, - "lodash.has": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz", - "integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=", - "dev": true - }, "lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -27233,7 +27620,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "requires": { "callsites": "^3.0.0" } @@ -29809,12 +30195,22 @@ } }, "prop-types-extra": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.0.tgz", - "integrity": "sha512-QFyuDxvMipmIVKD2TwxLVPzMnO4e5oOf1vr3tJIomL8E7d0lr6phTHd5nkPhFIzTD1idBLLEPeylL9g+rrTzRg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", "requires": { "react-is": "^16.3.2", - "warning": "^3.0.0" + "warning": "^4.0.0" + }, + "dependencies": { + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + } } }, "property-information": { @@ -30075,19 +30471,21 @@ } }, "react-bootstrap": { - "version": "0.31.5", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.31.5.tgz", - "integrity": "sha512-xgDihgX4QvYHmHzL87faDBMDnGfYyqcrqV0TEbWY+JizePOG1vfb8M3xJN+6MJ3kUYqDtQSZ7v/Q6Y5YDrkMdA==", + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-0.33.1.tgz", + "integrity": "sha512-qWTRravSds87P8WC82tETy2yIso8qDqlIm0czsrduCaYAFtHuyLu0XDbUlfLXeRzqgwm5sRk2wRaTNoiVkk/YQ==", "requires": { - "babel-runtime": "^6.11.6", + "@babel/runtime-corejs2": "^7.0.0", "classnames": "^2.2.5", "dom-helpers": "^3.2.0", - "invariant": "^2.2.1", - "keycode": "^2.1.2", - "prop-types": "^15.5.10", + "invariant": "^2.2.4", + "keycode": "^2.2.0", + "prop-types": "^15.6.1", "prop-types-extra": "^1.0.1", - "react-overlays": "^0.7.4", - "uncontrollable": "^4.1.0", + "react-overlays": "^0.9.0", + "react-prop-types": "^0.4.0", + "react-transition-group": "^2.0.0", + "uncontrollable": "^7.0.2", "warning": "^3.0.0" } }, @@ -30466,14 +30864,23 @@ "integrity": "sha512-p84kBqGaMoa7VYT0vZ/aOYRfJB+gw34yjpda1Z5KeLflg70HipZOT+MXQenEhdkPAABuE2Astq4zEPdMqUQxcg==" }, "react-overlays": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.7.4.tgz", - "integrity": "sha512-7vsooMx3siLAuEfTs8FYeP/lAORWWFXTO8PON3KgX0Htq1Oa+po6ioSjGyO0/GO5CVSMNhpWt6V2opeexHgBuQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.1.tgz", + "integrity": "sha512-b0asy/zHtRd0i2+2/uNxe3YVprF3bRT1guyr791DORjCzE/HSBMog+ul83CdtKQ1kZ+pLnxWCu5W3BMysFhHdQ==", "requires": { "classnames": "^2.2.5", "dom-helpers": "^3.2.1", "prop-types": "^15.5.10", "prop-types-extra": "^1.0.1", + "react-transition-group": "^2.2.1", + "warning": "^3.0.0" + } + }, + "react-prop-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz", + "integrity": "sha1-+ZsL+0AGkpya8gUefBQUpcdbk9A=", + "requires": { "warning": "^3.0.0" } }, @@ -30648,9 +31055,12 @@ } }, "react-table": { - "version": "7.0.0-rc.15", - "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.0.0-rc.15.tgz", - "integrity": "sha512-ofMOlgrioHhhvHjvjsQkxvfQzU98cqwy6BjPGNwhLN1vhgXeWi0mUGreaCPvRenEbTiXsQbMl4k3Xmx3Mut8Rw==" + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.0.4.tgz", + "integrity": "sha512-Uqpj+VnUIvsNWNtNFD1z2i7OCHdlhoJtQt0DWx3XOkZnvDyI/eCghK8YBfA9mY4TW7vEgCDLaRCcREC/fmcx6Q==", + "requires": { + "@scarf/scarf": "^0.1.5" + } }, "react-test-renderer": { "version": "16.9.0", @@ -30683,6 +31093,15 @@ "react-lifecycles-compat": "^3.0.4" } }, + "react-ultimate-pagination": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-ultimate-pagination/-/react-ultimate-pagination-1.2.0.tgz", + "integrity": "sha512-tBLzzskuBqsziQDUI98hA7FTBy2/Q5olRsvu3GdLYykfGUgDvYQOI7hLi9o5pD5zJeuAsVn3OoAUw0CaJi0WoQ==", + "requires": { + "prop-types": "^15.0.0", + "ultimate-pagination": "1.0.0" + } + }, "react-virtualized": { "version": "9.19.1", "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.19.1.tgz", @@ -31539,8 +31958,7 @@ "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, "resolve-pathname": { "version": "3.0.0", @@ -31602,6 +32020,11 @@ "inherits": "^2.0.1" } }, + "rison": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/rison/-/rison-0.1.1.tgz", + "integrity": "sha1-TcwFV7JBr/YOdheOd5ITVxPzMSA=" + }, "rst-selector-parser": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", @@ -32115,7 +32538,8 @@ }, "kind-of": { "version": "6.0.2", - "resolved": "" + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==" } } }, @@ -32211,9 +32635,9 @@ } }, "source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz", + "integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==", "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -33806,12 +34230,35 @@ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz", "integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==" }, + "ultimate-pagination": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ultimate-pagination/-/ultimate-pagination-1.0.0.tgz", + "integrity": "sha1-H59UZWeNdBAVnVoXLCATRl6b2F8=" + }, "uncontrollable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-4.1.0.tgz", - "integrity": "sha1-4DWCkSUuGGUiLZCTmxny9J+Bwak=", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.1.1.tgz", + "integrity": "sha512-EcPYhot3uWTS3w00R32R2+vS8Vr53tttrvMj/yA1uYRhf8hbTG2GyugGqWDY0qIskxn0uTTojVd6wPYW9ZEf8Q==", "requires": { - "invariant": "^2.1.0" + "@babel/runtime": "^7.6.3", + "@types/react": "^16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", + "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + } } }, "underscore": { @@ -33951,6 +34398,12 @@ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-1.1.2.tgz", "integrity": "sha512-yvo+MMLjEwdc3RhhPYSximset7rwjMrdt9E41Smmvg25UQIenzrN83cRnF1JMzoMi9zZOQeYXHSDf7p+IQkW3Q==" }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -35277,69 +35730,6 @@ } } }, - "webpack-assets-manifest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/webpack-assets-manifest/-/webpack-assets-manifest-3.1.1.tgz", - "integrity": "sha512-JV9V2QKc5wEWQptdIjvXDUL1ucbPLH2f27toAY3SNdGZp+xSaStAgpoMcvMZmqtFrBc9a5pTS1058vxyMPOzRQ==", - "dev": true, - "requires": { - "chalk": "^2.0", - "lodash.get": "^4.0", - "lodash.has": "^4.0", - "mkdirp": "^0.5", - "schema-utils": "^1.0.0", - "tapable": "^1.0.0", - "webpack-sources": "^1.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", - "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "schema-utils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", - "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", - "dev": true, - "requires": { - "ajv": "^6.1.0", - "ajv-errors": "^1.0.0", - "ajv-keywords": "^3.1.0" - } - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - }, - "tapable": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.1.tgz", - "integrity": "sha512-9I2ydhj8Z9veORCw5PRm4u9uebCn0mcCa6scWoNcbZ6dAtoo2618u9UUzxgmsCOreJpqDDuv61LvwofW7hLcBA==", - "dev": true - } - } - }, "webpack-bundle-analyzer": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.6.1.tgz", @@ -36771,6 +37161,26 @@ "uuid": "^3.3.2" } }, + "webpack-manifest-plugin": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-2.2.0.tgz", + "integrity": "sha512-9S6YyKKKh/Oz/eryM1RyLVDVmy3NSPV0JXMRhZ18fJsq+AwGxUY34X54VNwkzYcEmEkDwNxuEOboCZEebJXBAQ==", + "dev": true, + "requires": { + "fs-extra": "^7.0.0", + "lodash": ">=3.5 <5", + "object.entries": "^1.1.0", + "tapable": "^1.0.0" + }, + "dependencies": { + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true + } + } + }, "webpack-sources": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", @@ -36948,6 +37358,29 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "yaml": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.8.3.tgz", + "integrity": "sha512-X/v7VDnK+sxbQ2Imq4Jt2PRUsRsP7UcpSl3Llg6+NRRqWLIvxkMFYtH1FmvwNGYRKKPa+EPA4qDBlI9WVG1UKw==", + "requires": { + "@babel/runtime": "^7.8.7" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", + "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + } + } + }, "yargs": { "version": "12.0.5", "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index a5a161390ef4..137e766e5dab 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -15,8 +15,10 @@ "dev-server": "NODE_ENV=development BABEL_ENV=development node --max_old_space_size=4096 ./node_modules/webpack-dev-server/bin/webpack-dev-server.js --mode=development --progress", "prod": "node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js --mode=production --colors --progress", "build-dev": "cross-env NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=development webpack --mode=development --colors --progress", + "build-instrumented": "cross-env NODE_ENV=development BABEL_ENV=instrumented webpack --mode=development --colors --progress", "build": "cross-env NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=production webpack --mode=production --colors --progress", "lint": "eslint --ignore-path=.eslintignore --ext .js,.jsx,.ts,.tsx .", + "prettier-check": "prettier --check '{src,stylesheets}/**/*.{css,less,sass,scss}'", "lint-fix": "eslint --fix --ignore-path=.eslintignore --ext .js,.jsx,.ts,tsx . && npm run clean-css", "clean-css": "prettier --write '{src,stylesheets}/**/*.{css,less,sass,scss}'" }, @@ -53,10 +55,12 @@ "dependencies": { "@babel/runtime-corejs3": "^7.8.4", "@data-ui/sparkline": "^0.0.54", + "@emotion/core": "^10.0.28", + "@emotion/styled": "^10.0.27", "@superset-ui/chart": "^0.12.11", "@superset-ui/chart-composition": "^0.12.8", "@superset-ui/color": "^0.12.8", - "@superset-ui/connection": "^0.12.8", + "@superset-ui/connection": "^0.12.22", "@superset-ui/core": "^0.12.8", "@superset-ui/dimension": "^0.12.8", "@superset-ui/legacy-plugin-chart-calendar": "^0.11.15", @@ -77,11 +81,11 @@ "@superset-ui/legacy-plugin-chart-rose": "^0.11.15", "@superset-ui/legacy-plugin-chart-sankey": "^0.11.15", "@superset-ui/legacy-plugin-chart-sunburst": "^0.11.15", - "@superset-ui/legacy-plugin-chart-table": "^0.11.20", + "@superset-ui/legacy-plugin-chart-table": "^0.12.14", "@superset-ui/legacy-plugin-chart-treemap": "^0.11.15", "@superset-ui/legacy-plugin-chart-word-cloud": "^0.11.15", "@superset-ui/legacy-plugin-chart-world-map": "^0.11.15", - "@superset-ui/legacy-preset-chart-big-number": "^0.11.15", + "@superset-ui/legacy-preset-chart-big-number": "^0.12.13", "@superset-ui/legacy-preset-chart-deckgl": "^0.2.3", "@superset-ui/legacy-preset-chart-nvd3": "^0.11.15", "@superset-ui/number-format": "^0.12.10", @@ -90,8 +94,11 @@ "@superset-ui/query": "^0.12.8", "@superset-ui/time-format": "^0.12.10", "@superset-ui/translation": "^0.12.8", + "@superset-ui/validator": "^0.12.13", "@types/classnames": "^2.2.9", "@types/react-json-tree": "^0.6.11", + "@types/react-select": "^1.2.1", + "@types/rison": "0.0.6", "@vx/responsive": "^0.0.195", "abortcontroller-polyfill": "^1.1.9", "aphrodite": "^2.3.1", @@ -106,6 +113,7 @@ "d3-scale": "^2.1.2", "dnd-core": "^2.6.0", "dompurify": "^2.0.7", + "emotion-theming": "^10.0.27", "geolib": "^2.0.24", "immutable": "^3.8.2", "interweave": "^11.2.0", @@ -122,7 +130,7 @@ "re-resizable": "^4.3.1", "react": "^16.13.0", "react-ace": "^5.10.0", - "react-bootstrap": "^0.31.5", + "react-bootstrap": "^0.33.1", "react-bootstrap-dialog": "^0.10.0", "react-bootstrap-slider": "2.1.5", "react-checkbox-tree": "^1.5.1", @@ -145,8 +153,9 @@ "react-split": "^2.0.4", "react-sticky": "^6.0.2", "react-syntax-highlighter": "^7.0.4", - "react-table": "^7.0.0-rc.15", + "react-table": "^7.0.4", "react-transition-group": "^2.5.3", + "react-ultimate-pagination": "^1.2.0", "react-virtualized": "9.19.1", "react-virtualized-select": "^3.1.3", "reactable-arc": "0.14.42", @@ -155,6 +164,7 @@ "redux-thunk": "^2.1.0", "redux-undo": "^1.0.0-beta9-9-7", "regenerator-runtime": "^0.13.3", + "rison": "^0.1.1", "shortid": "^2.2.6", "urijs": "^1.18.10", "use-query-params": "^0.4.5" @@ -171,13 +181,16 @@ "@babel/preset-react": "^7.8.3", "@babel/register": "^7.8.6", "@hot-loader/react-dom": "^16.13.0", + "@types/classnames": "^2.2.9", + "@istanbuljs/nyc-config-typescript": "^1.0.1", "@types/jest": "^25.1.4", "@types/jquery": "^3.3.32", "@types/react": "^16.9.23", "@types/react-dom": "^16.9.5", + "@types/react-json-tree": "^0.6.11", "@types/react-redux": "^7.1.7", - "@types/react-select": "^3.0.10", "@types/react-table": "^7.0.2", + "@types/react-ultimate-pagination": "^1.2.0", "@types/yargs": "12 - 15", "@typescript-eslint/eslint-plugin": "^2.20.0", "@typescript-eslint/parser": "^2.20.0", @@ -185,12 +198,14 @@ "babel-jest": "^25.1.0", "babel-loader": "^8.0.6", "babel-plugin-dynamic-import-node": "^2.3.0", + "babel-plugin-emotion": "^10.0.29", "babel-plugin-lodash": "^3.3.4", "cache-loader": "^1.2.2", "clean-webpack-plugin": "^3.0.0", "copy-webpack-plugin": "^5.1.1", "cross-env": "^5.2.0", "css-loader": "^1.0.0", + "emotion-ts-plugin": "^0.5.3", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.14.0", "eslint": "^6.2.2", @@ -221,6 +236,7 @@ "react-test-renderer": "^16.9.0", "redux-mock-store": "^1.2.3", "sinon": "^4.5.0", + "source-map-support": "^0.5.16", "speed-measure-webpack-plugin": "^1.2.3", "style-loader": "^1.0.0", "terser-webpack-plugin": "^1.1.0", @@ -231,10 +247,10 @@ "typescript": "^3.8.3", "url-loader": "^1.0.1", "webpack": "^4.42.0", - "webpack-assets-manifest": "^3.1.1", "webpack-bundle-analyzer": "^3.6.1", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.10.3", + "webpack-manifest-plugin": "^2.2.0", "webpack-sources": "^1.4.3", "yargs": "12 - 15" }, diff --git a/superset-frontend/spec/javascripts/chart/chartActions_spec.js b/superset-frontend/spec/javascripts/chart/chartActions_spec.js index 56e8cfb63864..aa1cffa5fa96 100644 --- a/superset-frontend/spec/javascripts/chart/chartActions_spec.js +++ b/superset-frontend/spec/javascripts/chart/chartActions_spec.js @@ -141,7 +141,7 @@ describe('chart actions', () => { { overwriteRoutes: true }, ); - const timeoutInSec = 1 / 1000; + const timeoutInSec = 100; // Set to a time that is longer than the time this will take to fail const actionThunk = actions.postChartFormData({}, false, timeoutInSec); return actionThunk(dispatch).then(() => { diff --git a/superset-frontend/spec/javascripts/components/AlteredSliceTag_spec.jsx b/superset-frontend/spec/javascripts/components/AlteredSliceTag_spec.jsx index 63902ab574c4..6adf3979eb2c 100644 --- a/superset-frontend/spec/javascripts/components/AlteredSliceTag_spec.jsx +++ b/superset-frontend/spec/javascripts/components/AlteredSliceTag_spec.jsx @@ -18,8 +18,8 @@ */ import React from 'react'; import { shallow } from 'enzyme'; - import { Table, Thead, Td, Th, Tr } from 'reactable-arc'; +import { getChartControlPanelRegistry } from '@superset-ui/chart'; import AlteredSliceTag from '../../../src/components/AlteredSliceTag'; import ModalTrigger from '../../../src/components/ModalTrigger'; @@ -27,6 +27,7 @@ import TooltipWrapper from '../../../src/components/TooltipWrapper'; const defaultProps = { origFormData: { + viz_type: 'altered_slice_tag_spec', adhoc_filters: [ { clause: 'WHERE', @@ -111,12 +112,53 @@ const expectedDiffs = { }, }; +const fakePluginControls = { + controlPanelSections: [ + { + label: 'Fake Control Panel Sections', + expanded: true, + controlSetRows: [ + [ + { + name: 'y_axis_bounds', + config: { + type: 'BoundsControl', + label: 'Value bounds', + default: [null, null], + description: 'Value bounds for the y axis', + }, + }, + { + name: 'column_collection', + config: { + type: 'CollectionControl', + label: 'Fake Collection Control', + }, + }, + { + name: 'adhoc_filters', + config: { + type: 'AdhocFilterControl', + label: 'Fake Filters', + default: null, + }, + }, + ], + ], + }, + ], +}; + describe('AlteredSliceTag', () => { let wrapper; let props; beforeEach(() => { - props = Object.assign({}, defaultProps); + getChartControlPanelRegistry().registerValue( + 'altered_slice_tag_spec', + fakePluginControls, + ); + props = { ...defaultProps }; wrapper = shallow(); }); @@ -140,8 +182,8 @@ describe('AlteredSliceTag', () => { it('sets new diffs when receiving new props', () => { const newProps = { - currentFormData: Object.assign({}, props.currentFormData), - origFormData: Object.assign({}, props.origFormData), + currentFormData: { ...props.currentFormData }, + origFormData: { ...props.origFormData }, }; newProps.currentFormData.beta = 10; wrapper = shallow(); @@ -237,6 +279,7 @@ describe('AlteredSliceTag', () => { }); it('returns "Max" and "Min" for BoundsControl', () => { + // need to pass the viz type to the wrapper expect(wrapper.instance().formatValue([5, 6], 'y_axis_bounds')).toBe( 'Min: 5, Max: 6', ); diff --git a/superset-frontend/spec/javascripts/components/AsyncSelect_spec.jsx b/superset-frontend/spec/javascripts/components/AsyncSelect_spec.jsx index cb0281526c03..30faddd88e6e 100644 --- a/superset-frontend/spec/javascripts/components/AsyncSelect_spec.jsx +++ b/superset-frontend/spec/javascripts/components/AsyncSelect_spec.jsx @@ -113,7 +113,7 @@ describe('AsyncSelect', () => { }); }); - it('should call onAsyncError if there is an error fetching options', done => { + it('should call onAsyncError if there is an error fetching options', () => { expect.assertions(3); const errorEndpoint = 'async/error/'; @@ -121,7 +121,7 @@ describe('AsyncSelect', () => { fetchMock.get(errorGlob, { throws: 'error' }); const onAsyncError = jest.fn(); - shallow( + const wrapper = shallow( { />, ); - setTimeout(() => { - expect(fetchMock.calls(errorGlob)).toHaveLength(1); - expect(onAsyncError.mock.calls).toHaveLength(1); - expect(onAsyncError).toBeCalledWith('error'); - done(); - }); + return wrapper + .instance() + .fetchOptions() + .then(() => { + // Fails then retries thrice whenever fetching options, which happens twice: + // once on component mount and once when calling `fetchOptions` again + expect(fetchMock.calls(errorGlob)).toHaveLength(8); + expect(onAsyncError.mock.calls).toHaveLength(2); + expect(onAsyncError).toBeCalledWith('error'); + + return Promise.resolve(); + }); }); }); }); diff --git a/superset-frontend/spec/javascripts/components/Checkbox_spec.jsx b/superset-frontend/spec/javascripts/components/Checkbox_spec.jsx index 6a8573eca6c1..5f6fb697c9a6 100644 --- a/superset-frontend/spec/javascripts/components/Checkbox_spec.jsx +++ b/superset-frontend/spec/javascripts/components/Checkbox_spec.jsx @@ -30,7 +30,7 @@ describe('Checkbox', () => { let wrapper; const factory = o => { - const props = Object.assign({}, defaultProps, o); + const props = { ...defaultProps, ...o }; return shallow(); }; beforeEach(() => { diff --git a/superset-frontend/spec/javascripts/components/ColumnOption_spec.jsx b/superset-frontend/spec/javascripts/components/ColumnOption_spec.jsx index 26650da62cbc..cbca4f768c28 100644 --- a/superset-frontend/spec/javascripts/components/ColumnOption_spec.jsx +++ b/superset-frontend/spec/javascripts/components/ColumnOption_spec.jsx @@ -39,7 +39,7 @@ describe('ColumnOption', () => { const factory = o => ; beforeEach(() => { wrapper = shallow(factory(defaultProps)); - props = Object.assign({}, defaultProps); + props = { ...defaultProps }; }); it('is a valid element', () => { expect(React.isValidElement()).toBe(true); diff --git a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx index 94ee989c5c4c..61aca5f8b695 100644 --- a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx +++ b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx @@ -20,42 +20,49 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { MenuItem, Pagination } from 'react-bootstrap'; +import Select from 'react-select'; import ListView from 'src/components/ListView/ListView'; +import ListViewFilters from 'src/components/ListView/Filters'; +import ListViewPagination from 'src/components/ListView/Pagination'; import { areArraysShallowEqual } from 'src/reduxUtils'; +const mockedProps = { + title: 'Data Table', + columns: [ + { + accessor: 'id', + Header: 'ID', + sortable: true, + }, + { + accessor: 'age', + Header: 'Age', + }, + { + accessor: 'name', + Header: 'Name', + }, + ], + filters: [ + { + Header: 'Name', + id: 'name', + operators: [{ label: 'Starts With', value: 'sw' }], + }, + ], + data: [ + { id: 1, name: 'data 1' }, + { id: 2, name: 'data 2' }, + ], + count: 2, + pageSize: 1, + fetchData: jest.fn(() => []), + loading: false, + bulkActions: [{ name: 'do something', onSelect: jest.fn() }], +}; + describe('ListView', () => { - const mockedProps = { - title: 'Data Table', - columns: [ - { - accessor: 'id', - Header: 'ID', - sortable: true, - }, - { - accessor: 'name', - Header: 'Name', - filterable: true, - }, - ], - filters: [ - { - Header: 'Name', - id: 'name', - operators: [{ label: 'Starts With', value: 'sw' }], - }, - ], - data: [ - { id: 1, name: 'data 1' }, - { id: 2, name: 'data 2' }, - ], - count: 2, - pageSize: 1, - fetchData: jest.fn(() => []), - loading: false, - bulkActions: [{ name: 'do something', onSelect: jest.fn() }], - }; const wrapper = mount(); afterEach(() => { @@ -137,58 +144,64 @@ describe('ListView', () => { wrapper.update(); expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": Array [ - Object { - "Header": "name", - "id": "name", - "operator": "sw", - "value": "foo", - }, - ], - "pageIndex": 0, - "pageSize": 1, - "sortBy": Array [ - Object { - "desc": false, - "id": "id", - }, - ], - }, - ] - `); +Array [ + Object { + "filters": Array [ + Object { + "id": "name", + "operator": "sw", + "value": "foo", + }, + ], + "pageIndex": 0, + "pageSize": 1, + "sortBy": Array [ + Object { + "desc": false, + "id": "id", + }, + ], + }, +] +`); + }); + + it('renders pagination controls', () => { + expect(wrapper.find(Pagination).exists()).toBe(true); + expect(wrapper.find(Pagination.Prev).exists()).toBe(true); + expect(wrapper.find(Pagination.Item).exists()).toBe(true); + expect(wrapper.find(Pagination.Next).exists()).toBe(true); }); it('calls fetchData on page change', () => { act(() => { - wrapper.find(Pagination).prop('onSelect')(2); + wrapper.find(ListViewPagination).prop('onChange')(2); }); wrapper.update(); expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": Array [ - Object { - "Header": "name", - "id": "name", - "operator": "sw", - "value": "foo", - }, - ], - "pageIndex": 1, - "pageSize": 1, - "sortBy": Array [ - Object { - "desc": false, - "id": "id", - }, - ], - }, - ] - `); +Array [ + Object { + "filters": Array [ + Object { + "id": "name", + "operator": "sw", + "value": "foo", + }, + ], + "pageIndex": 1, + "pageSize": 1, + "sortBy": Array [ + Object { + "desc": false, + "id": "id", + }, + ], + }, +] +`); }); + it('handles bulk actions on 1 row', () => { act(() => { wrapper @@ -222,6 +235,7 @@ describe('ListView', () => { ] `); }); + it('handles bulk actions on all rows', () => { act(() => { wrapper @@ -259,6 +273,7 @@ describe('ListView', () => { ] `); }); + it('Throws an exception if filter missing in columns', () => { expect.assertions(1); const props = { @@ -274,3 +289,117 @@ describe('ListView', () => { } }); }); + +describe('ListView with new UI filters', () => { + const fetchSelectsMock = jest.fn(() => []); + const newFiltersProps = { + ...mockedProps, + useNewUIFilters: true, + filters: [ + { + Header: 'ID', + id: 'id', + input: 'select', + selects: [{ label: 'foo', value: 'bar' }], + operator: 'eq', + }, + { + Header: 'Name', + id: 'name', + input: 'search', + operator: 'ct', + }, + { + Header: 'Age', + id: 'age', + input: 'select', + fetchSelects: fetchSelectsMock, + operator: 'eq', + }, + ], + }; + + const wrapper = mount(); + + afterEach(() => { + mockedProps.fetchData.mockClear(); + mockedProps.bulkActions.forEach(ba => { + ba.onSelect.mockClear(); + }); + }); + + it('renders UI filters', () => { + expect(wrapper.find(ListViewFilters)).toHaveLength(1); + }); + + it('fetched selects if function is provided', () => { + expect(fetchSelectsMock).toHaveBeenCalled(); + }); + + it('calls fetchData on filter', () => { + act(() => { + wrapper + .find('[data-test="filters-select"]') + .first() + .props() + .onChange({ value: 'bar' }); + }); + + act(() => { + wrapper + .find('[data-test="filters-search"]') + .first() + .props() + .onChange({ currentTarget: { value: 'something' } }); + }); + + wrapper.update(); + + act(() => { + wrapper + .find('[data-test="filters-search"]') + .last() + .props() + .onBlur(); + }); + + expect(newFiltersProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "filters": Array [ + Object { + "id": "id", + "operator": "eq", + "value": "bar", + }, + ], + "pageIndex": 0, + "pageSize": 1, + "sortBy": Array [], + }, +] +`); + + expect(newFiltersProps.fetchData.mock.calls[1]).toMatchInlineSnapshot(` +Array [ + Object { + "filters": Array [ + Object { + "id": "id", + "operator": "eq", + "value": "bar", + }, + Object { + "id": "name", + "operator": "ct", + "value": "something", + }, + ], + "pageIndex": 0, + "pageSize": 1, + "sortBy": Array [], + }, +] +`); + }); +}); diff --git a/superset-frontend/spec/javascripts/components/MetricOption_spec.jsx b/superset-frontend/spec/javascripts/components/MetricOption_spec.jsx index cd9c0e8de60e..1583f7ec3db8 100644 --- a/superset-frontend/spec/javascripts/components/MetricOption_spec.jsx +++ b/superset-frontend/spec/javascripts/components/MetricOption_spec.jsx @@ -40,7 +40,7 @@ describe('MetricOption', () => { const factory = o => ; beforeEach(() => { wrapper = shallow(factory(defaultProps)); - props = Object.assign({}, defaultProps); + props = { ...defaultProps }; }); it('is a valid element', () => { expect(React.isValidElement()).toBe(true); diff --git a/superset-frontend/spec/javascripts/components/OnPasteSelect_spec.jsx b/superset-frontend/spec/javascripts/components/OnPasteSelect_spec.jsx index c5f5742799c8..5bc6cb4d5ccf 100644 --- a/superset-frontend/spec/javascripts/components/OnPasteSelect_spec.jsx +++ b/superset-frontend/spec/javascripts/components/OnPasteSelect_spec.jsx @@ -54,9 +54,9 @@ describe('OnPasteSelect', () => { let evt; let expected; beforeEach(() => { - props = Object.assign({}, defaultProps); + props = { ...defaultProps }; wrapper = shallow(); - evt = Object.assign({}, defaultEvt); + evt = { ...defaultEvt }; }); it('renders the supplied selectWrap component', () => { diff --git a/superset-frontend/spec/javascripts/components/OptionDescription_spec.jsx b/superset-frontend/spec/javascripts/components/OptionDescription_spec.jsx index 5c0cd08d87a6..d3584cd5bf2e 100644 --- a/superset-frontend/spec/javascripts/components/OptionDescription_spec.jsx +++ b/superset-frontend/spec/javascripts/components/OptionDescription_spec.jsx @@ -34,7 +34,7 @@ describe('OptionDescription', () => { let props; beforeEach(() => { - props = { option: Object.assign({}, defaultProps.option) }; + props = { option: { ...defaultProps.option } }; wrapper = shallow(); }); diff --git a/superset-frontend/spec/javascripts/components/PopoverSection_spec.jsx b/superset-frontend/spec/javascripts/components/PopoverSection_spec.jsx index 28fda4ded79f..dce656ec2821 100644 --- a/superset-frontend/spec/javascripts/components/PopoverSection_spec.jsx +++ b/superset-frontend/spec/javascripts/components/PopoverSection_spec.jsx @@ -32,7 +32,7 @@ describe('PopoverSection', () => { let wrapper; const factory = overrideProps => { - const props = Object.assign({}, defaultProps, overrideProps || {}); + const props = { ...defaultProps, ...(overrideProps || {}) }; return shallow(); }; beforeEach(() => { diff --git a/superset-frontend/spec/javascripts/components/VirtualizedRendererWrap_spec.jsx b/superset-frontend/spec/javascripts/components/VirtualizedRendererWrap_spec.jsx index fdb7c4ca9cbb..2e467aa86f5e 100644 --- a/superset-frontend/spec/javascripts/components/VirtualizedRendererWrap_spec.jsx +++ b/superset-frontend/spec/javascripts/components/VirtualizedRendererWrap_spec.jsx @@ -48,7 +48,7 @@ describe('VirtualizedRendererWrap', () => { let props; beforeEach(() => { wrapper = shallow(); - props = Object.assign({}, defaultProps); + props = { ...defaultProps }; }); it('uses the provided renderer', () => { diff --git a/superset-frontend/spec/javascripts/dashboard/components/FilterIndicatorsContainer_spec.jsx b/superset-frontend/spec/javascripts/dashboard/components/FilterIndicatorsContainer_spec.jsx index 089f18540902..a256a9e3d1aa 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/FilterIndicatorsContainer_spec.jsx +++ b/superset-frontend/spec/javascripts/dashboard/components/FilterIndicatorsContainer_spec.jsx @@ -85,4 +85,25 @@ describe('FilterIndicatorsContainer', () => { const wrapper = setup({ dashboardFilters: overwriteDashboardFilters }); expect(wrapper.find(FilterIndicator)).toHaveLength(0); }); + + it('should show single number type value', () => { + const overwriteDashboardFilters = { + ...dashboardFilters, + [filterId]: { + ...dashboardFilters[filterId], + columns: { + testField: 0, + }, + }, + }; + const wrapper = setup({ dashboardFilters: overwriteDashboardFilters }); + expect(wrapper.find(FilterIndicator)).toHaveLength(1); + + const indicatorProps = wrapper + .find(FilterIndicator) + .first() + .props().indicator; + expect(indicatorProps.label).toEqual('testField'); + expect(indicatorProps.values).toEqual([0]); + }); }); diff --git a/superset-frontend/spec/javascripts/dashboard/util/getFilterScopeFromNodesTree_spec.js b/superset-frontend/spec/javascripts/dashboard/util/getFilterScopeFromNodesTree_spec.js index 067e193a712c..a2c94f3491f3 100644 --- a/superset-frontend/spec/javascripts/dashboard/util/getFilterScopeFromNodesTree_spec.js +++ b/superset-frontend/spec/javascripts/dashboard/util/getFilterScopeFromNodesTree_spec.js @@ -212,5 +212,19 @@ describe('getFilterScopeFromNodesTree', () => { immune: [], }); }); + + it('mixed row level tab and chart scope', () => { + const checkedChartIds = [103, 105, 102]; + expect( + getFilterScopeFromNodesTree({ + filterKey: '107_region', + nodes, + checkedChartIds, + }), + ).toEqual({ + scope: ['TAB-E4mJaZ-uQM', 'TAB-rLYu-Cryu'], + immune: [101], + }); + }); }); }); diff --git a/superset-frontend/spec/javascripts/explore/AdhocFilter_spec.js b/superset-frontend/spec/javascripts/explore/AdhocFilter_spec.js index a559bd709901..dd4818128e36 100644 --- a/superset-frontend/spec/javascripts/explore/AdhocFilter_spec.js +++ b/superset-frontend/spec/javascripts/explore/AdhocFilter_spec.js @@ -40,6 +40,7 @@ describe('AdhocFilter', () => { filterOptionName: adhocFilter.filterOptionName, sqlExpression: null, fromFormData: false, + isExtra: false, }); }); diff --git a/superset-frontend/spec/javascripts/explore/components/ControlPanelSection_spec.jsx b/superset-frontend/spec/javascripts/explore/components/ControlPanelSection_spec.jsx index 7e4f7478e9f2..ab554caae5fa 100644 --- a/superset-frontend/spec/javascripts/explore/components/ControlPanelSection_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/components/ControlPanelSection_spec.jsx @@ -57,7 +57,7 @@ describe('ControlPanelSection', () => { it('renders a label if present', () => { expect( wrapper - .find(Panel) + .find(Panel.Title) .dive() .text(), ).toContain('my label'); diff --git a/superset-frontend/spec/javascripts/explore/components/TextArea_spec.jsx b/superset-frontend/spec/javascripts/explore/components/TextArea_spec.jsx index 1911c6acb282..1a18691e616a 100644 --- a/superset-frontend/spec/javascripts/explore/components/TextArea_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/components/TextArea_spec.jsx @@ -48,7 +48,7 @@ describe('SelectControl', () => { }); it('renders a AceEditor when language is specified', () => { - const props = Object.assign({}, defaultProps); + const props = { ...defaultProps }; props.language = 'markdown'; wrapper = shallow(); expect(wrapper.find(FormControl)).toHaveLength(0); diff --git a/superset-frontend/spec/javascripts/explore/controlUtils_spec.jsx b/superset-frontend/spec/javascripts/explore/controlUtils_spec.jsx index 2725a9a975e0..b2bb700681bf 100644 --- a/superset-frontend/spec/javascripts/explore/controlUtils_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/controlUtils_spec.jsx @@ -57,6 +57,23 @@ describe('controlUtils', () => { }, }, ], + [ + { + name: 'stacked_style', + config: { + type: 'SelectControl', + label: t('Stacked Style'), + renderTrigger: true, + choices: [ + ['stack', 'stack'], + ['stream', 'stream'], + ['expand', 'expand'], + ], + default: 'stack', + description: '', + }, + }, + ], ], }, ], @@ -148,10 +165,15 @@ describe('controlUtils', () => { }); it('removes missing/invalid choice', () => { - let control = getControlState('stacked_style', 'area', state, 'stack'); + let control = getControlState( + 'stacked_style', + 'test-chart', + state, + 'stack', + ); expect(control.value).toBe('stack'); - control = getControlState('stacked_style', 'area', state, 'FOO'); + control = getControlState('stacked_style', 'test-chart', state, 'FOO'); expect(control.value).toBe(null); }); diff --git a/superset-frontend/spec/javascripts/profile/fixtures.jsx b/superset-frontend/spec/javascripts/profile/fixtures.jsx index 2b378c5adb72..cd434248cbb6 100644 --- a/superset-frontend/spec/javascripts/profile/fixtures.jsx +++ b/superset-frontend/spec/javascripts/profile/fixtures.jsx @@ -43,4 +43,4 @@ export const user = { database_access: ['db1', 'db2', 'db3'], }, }; -export const userNoPerms = Object.assign({}, user, { permissions: {} }); +export const userNoPerms = { ...user, permissions: {} }; diff --git a/superset-frontend/spec/javascripts/sqllab/ResultSet_spec.jsx b/superset-frontend/spec/javascripts/sqllab/ResultSet_spec.jsx index 803083ef209a..886459ad7aa3 100644 --- a/superset-frontend/spec/javascripts/sqllab/ResultSet_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/ResultSet_spec.jsx @@ -38,15 +38,9 @@ describe('ResultSet', () => { query: queries[0], height: 0, }; - const stoppedQueryProps = Object.assign({}, mockedProps, { - query: stoppedQuery, - }); - const runningQueryProps = Object.assign({}, mockedProps, { - query: runningQuery, - }); - const cachedQueryProps = Object.assign({}, mockedProps, { - query: cachedQuery, - }); + const stoppedQueryProps = { ...mockedProps, query: stoppedQuery }; + const runningQueryProps = { ...mockedProps, query: runningQuery }; + const cachedQueryProps = { ...mockedProps, query: cachedQuery }; const newProps = { query: { cached: false, @@ -94,11 +88,12 @@ describe('ResultSet', () => { }); it('should render empty results', () => { const wrapper = shallow(); - const emptyResults = Object.assign({}, queries[0], { + const emptyResults = { + ...queries[0], results: { data: [], }, - }); + }; wrapper.setProps({ query: emptyResults }); expect(wrapper.find(FilterableTable)).toHaveLength(0); expect(wrapper.find(Alert)).toHaveLength(1); diff --git a/superset-frontend/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx b/superset-frontend/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx index 4ee8091f32dd..4670a947d5a3 100644 --- a/superset-frontend/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx @@ -132,7 +132,8 @@ describe('ShareSqlLabQuery via /kv/store', () => { .instance() .getCopyUrl() .then(() => { - expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1); + // Fails then retries thrice + expect(fetchMock.calls(storeQueryUrl)).toHaveLength(4); expect(addDangerToastSpy.mock.calls).toHaveLength(1); expect(addDangerToastSpy.mock.calls[0][0]).toBe(error); diff --git a/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx b/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx index cb1002f5de97..a97cebdbab68 100644 --- a/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx @@ -18,6 +18,7 @@ */ import React from 'react'; import { shallow } from 'enzyme'; +import { Checkbox } from 'react-bootstrap'; import { defaultQueryEditor, initialState, queries, table } from './fixtures'; import { @@ -105,4 +106,13 @@ describe('SqlEditor', () => { queryEditor.queryLimit, ); }); + it('allows toggling autocomplete', () => { + const wrapper = shallow(); + expect(wrapper.find(AceEditorWrapper).props().autocomplete).toBe(true); + wrapper + .find(Checkbox) + .props() + .onChange(); + expect(wrapper.find(AceEditorWrapper).props().autocomplete).toBe(false); + }); }); diff --git a/superset-frontend/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx b/superset-frontend/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx index eed1dd921c2e..cac5bdbe5bda 100644 --- a/superset-frontend/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx @@ -37,10 +37,7 @@ describe('TabbedSqlEditors', () => { const tabHistory = ['dfsadfs', 'newEditorId']; const tables = [ - Object.assign({}, table, { - dataPreviewQueryId: 'B1-VQU1zW', - queryEditorId: 'newEditorId', - }), + { ...table, dataPreviewQueryId: 'B1-VQU1zW', queryEditorId: 'newEditorId' }, ]; const queryEditors = [ diff --git a/superset-frontend/spec/javascripts/sqllab/fixtures.js b/superset-frontend/spec/javascripts/sqllab/fixtures.js index 7d68c32d9547..c6de52812612 100644 --- a/superset-frontend/spec/javascripts/sqllab/fixtures.js +++ b/superset-frontend/spec/javascripts/sqllab/fixtures.js @@ -19,7 +19,7 @@ import sinon from 'sinon'; import * as actions from '../../../src/SqlLab/actions/sqlLab'; -export const mockedActions = sinon.stub(Object.assign({}, actions)); +export const mockedActions = sinon.stub({ ...actions }); export const alert = { bsStyle: 'danger', msg: 'Ooops', id: 'lksvmcx32' }; export const table = { @@ -388,7 +388,7 @@ export const runningQuery = { state: 'running', startDttm: Date.now() - 500, }; -export const cachedQuery = Object.assign({}, queries[0], { cached: true }); +export const cachedQuery = { ...queries[0], cached: true }; export const initialState = { sqlLab: { diff --git a/superset-frontend/spec/javascripts/sqllab/reducers/sqlLab_spec.js b/superset-frontend/spec/javascripts/sqllab/reducers/sqlLab_spec.js index 87a0deb3213f..8a821bbcafb1 100644 --- a/superset-frontend/spec/javascripts/sqllab/reducers/sqlLab_spec.js +++ b/superset-frontend/spec/javascripts/sqllab/reducers/sqlLab_spec.js @@ -141,7 +141,7 @@ describe('sqlLabReducer', () => { let newState; let newTable; beforeEach(() => { - newTable = Object.assign({}, table); + newTable = { ...table }; const action = { type: actions.MERGE_TABLE, table: newTable, diff --git a/superset-frontend/spec/javascripts/utils/getControlsForVizType_spec.js b/superset-frontend/spec/javascripts/utils/getControlsForVizType_spec.js new file mode 100644 index 000000000000..167863af82ca --- /dev/null +++ b/superset-frontend/spec/javascripts/utils/getControlsForVizType_spec.js @@ -0,0 +1,104 @@ +/** + * 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 { getChartControlPanelRegistry } from '@superset-ui/chart'; +import getControlsForVizType from 'src/utils/getControlsForVizType'; + +const fakePluginControls = { + controlPanelSections: [ + { + label: 'Fake Control Panel Sections', + expanded: true, + controlSetRows: [ + ['url_params'], + [ + { + name: 'y_axis_bounds', + config: { + type: 'BoundsControl', + label: 'Value bounds', + default: [null, null], + description: 'Value bounds for the y axis', + }, + }, + ], + [ + { + name: 'adhoc_filters', + config: { + type: 'AdhocFilterControl', + label: 'Fake Filters', + default: null, + }, + }, + ], + ], + }, + { + label: 'Fake Control Panel Sections 2', + expanded: true, + controlSetRows: [ + [ + { + name: 'column_collection', + config: { + type: 'CollectionControl', + label: 'Fake Collection Control', + }, + }, + ], + ], + }, + ], +}; + +describe('getControlsForVizType', () => { + beforeEach(() => { + getChartControlPanelRegistry().registerValue( + 'chart_controls_inventory_fake', + fakePluginControls, + ); + }); + + it('returns a map of the controls', () => { + expect(getControlsForVizType('chart_controls_inventory_fake')).toEqual({ + url_params: { + type: 'HiddenControl', + label: 'URL Parameters', + hidden: true, + description: 'Extra parameters for use in jinja templated queries', + }, + y_axis_bounds: { + type: 'BoundsControl', + label: 'Value bounds', + default: [null, null], + description: 'Value bounds for the y axis', + }, + adhoc_filters: { + type: 'AdhocFilterControl', + label: 'Fake Filters', + default: null, + }, + column_collection: { + type: 'CollectionControl', + label: 'Fake Collection Control', + }, + }); + }); +}); diff --git a/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx b/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx index 60c8ccb1f554..faf0c096d1e1 100644 --- a/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx @@ -32,6 +32,8 @@ const store = mockStore({}); const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*'; const chartssOwnersEndpoint = 'glob:*/api/v1/chart/related/owners*'; const chartsEndpoint = 'glob:*/api/v1/chart/?*'; +const chartsVizTypesEndpoint = 'glob:*/api/v1/chart/viz_types'; +const chartsDtasourcesEndpoint = 'glob:*/api/v1/chart/datasources'; const mockCharts = [...new Array(3)].map((_, i) => ({ changed_on: new Date().toISOString(), @@ -40,6 +42,7 @@ const mockCharts = [...new Array(3)].map((_, i) => ({ slice_name: `cool chart ${i}`, url: 'url', viz_type: 'bar', + datasource_name: `ds${i}`, })); fetchMock.get(chartsInfoEndpoint, { @@ -60,6 +63,16 @@ fetchMock.get(chartsEndpoint, { chart_count: 3, }); +fetchMock.get(chartsVizTypesEndpoint, { + result: [], + count: 0, +}); + +fetchMock.get(chartsDtasourcesEndpoint, { + result: [], + count: 0, +}); + describe('ChartList', () => { const mockedProps = {}; const wrapper = mount(, { diff --git a/superset-frontend/spec/javascripts/views/dashboardList/DashboardList_spec.jsx b/superset-frontend/spec/javascripts/views/dashboardList/DashboardList_spec.jsx index ba04a8db580f..830c9b9c9dcd 100644 --- a/superset-frontend/spec/javascripts/views/dashboardList/DashboardList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/dashboardList/DashboardList_spec.jsx @@ -94,8 +94,7 @@ describe('DashboardList', () => { `"/http//localhost/api/v1/dashboard/?q={%22order_column%22:%22changed_on%22,%22order_direction%22:%22desc%22,%22page%22:0,%22page_size%22:25}"`, ); }); - - it('edits', async () => { + it('edits', () => { expect(wrapper.find(PropertiesModal)).toHaveLength(0); wrapper .find('.fa-pencil') diff --git a/superset-frontend/src/SqlLab/App.jsx b/superset-frontend/src/SqlLab/App.jsx index 585fede9aa9b..0b3cbbbd58f9 100644 --- a/superset-frontend/src/SqlLab/App.jsx +++ b/superset-frontend/src/SqlLab/App.jsx @@ -46,7 +46,9 @@ setupApp(); const appContainer = document.getElementById('app'); const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap')); + initFeatureFlags(bootstrapData.common.feature_flags); + const initialState = getInitialState(bootstrapData); const sqlLabPersistStateConfig = { paths: ['sqlLab'], @@ -59,7 +61,6 @@ const sqlLabPersistStateConfig = { // it caused configurations passed from server-side got override. // see PR 6257 for details delete state[path].common; // eslint-disable-line no-param-reassign - if (path === 'sqlLab') { subset[path] = { ...state[path], diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index 9dad773219fe..eb514b2c5032 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -1249,3 +1249,23 @@ export function createDatasource(vizOptions) { }); }; } + +export function createCtasDatasource(vizOptions) { + return dispatch => { + dispatch(createDatasourceStarted()); + return SupersetClient.post({ + endpoint: '/superset/get_or_create_table/', + postPayload: { data: vizOptions }, + }) + .then(({ json }) => { + dispatch(createDatasourceSuccess(json)); + + return json; + }) + .catch(() => { + const errorMsg = t('An error occurred while creating the data source'); + dispatch(createDatasourceFailed(errorMsg)); + return Promise.reject(new Error(errorMsg)); + }); + }; +} diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper.jsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx similarity index 82% rename from superset-frontend/src/SqlLab/components/AceEditorWrapper.jsx rename to superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx index aa31827d6684..370cdbcb910c 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper.jsx +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx @@ -17,7 +17,6 @@ * under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import AceEditor from 'react-ace'; import 'brace/mode/sql'; import 'brace/theme/github'; @@ -34,41 +33,53 @@ import { const langTools = ace.acequire('ace/ext/language_tools'); -const propTypes = { - actions: PropTypes.object.isRequired, - onBlur: PropTypes.func, - sql: PropTypes.string.isRequired, - schemas: PropTypes.array, - tables: PropTypes.array, - functionNames: PropTypes.array, - extendedTables: PropTypes.array, - queryEditor: PropTypes.object.isRequired, - height: PropTypes.string, - hotkeys: PropTypes.arrayOf( - PropTypes.shape({ - key: PropTypes.string.isRequired, - descr: PropTypes.string.isRequired, - func: PropTypes.func.isRequired, - }), - ), - onChange: PropTypes.func, +type HotKey = { + key: string; + descr: string; + name: string; + func: () => void; }; -const defaultProps = { - onBlur: () => {}, - onChange: () => {}, - schemas: [], - tables: [], - functionNames: [], - extendedTables: [], -}; +interface Props { + actions: { + queryEditorSetSelectedText: (edit: any, text: null | string) => void; + addTable: (queryEditor: any, value: any, schema: any) => void; + }; + autocomplete: boolean; + onBlur: (sql: string) => void; + sql: string; + schemas: any[]; + tables: any[]; + functionNames: string[]; + extendedTables: Array<{ name: string; columns: any[] }>; + queryEditor: any; + height: string; + hotkeys: HotKey[]; + onChange: (sql: string) => void; +} + +interface State { + sql: string; + selectedText: string; + words: any[]; +} + +class AceEditorWrapper extends React.PureComponent { + static defaultProps = { + onBlur: () => {}, + onChange: () => {}, + schemas: [], + tables: [], + functionNames: [], + extendedTables: [], + }; -class AceEditorWrapper extends React.PureComponent { - constructor(props) { + constructor(props: Props) { super(props); this.state = { sql: props.sql, selectedText: '', + words: [], }; this.onChange = this.onChange.bind(this); } @@ -77,7 +88,7 @@ class AceEditorWrapper extends React.PureComponent { this.props.actions.queryEditorSetSelectedText(this.props.queryEditor, null); this.setAutoCompleter(this.props); } - UNSAFE_componentWillReceiveProps(nextProps) { + UNSAFE_componentWillReceiveProps(nextProps: Props) { if ( !areArraysShallowEqual(this.props.tables, nextProps.tables) || !areArraysShallowEqual(this.props.schemas, nextProps.schemas) || @@ -98,7 +109,7 @@ class AceEditorWrapper extends React.PureComponent { onAltEnter() { this.props.onBlur(this.state.sql); } - onEditorLoad(editor) { + onEditorLoad(editor: any) { editor.commands.addCommand({ name: 'runQuery', bindKey: { win: 'Alt-enter', mac: 'Alt-enter' }, @@ -129,18 +140,24 @@ class AceEditorWrapper extends React.PureComponent { } }); } - onChange(text) { + onChange(text: string) { this.setState({ sql: text }); this.props.onChange(text); } - getCompletions(aceEditor, session, pos, prefix, callback) { + getCompletions( + aceEditor: any, + session: any, + pos: any, + prefix: string, + callback: (p0: any, p1: any[]) => void, + ) { // If the prefix starts with a number, don't try to autocomplete with a // table name or schema or anything else if (!isNaN(parseInt(prefix, 10))) { return; } const completer = { - insertMatch: (editor, data) => { + insertMatch: (editor: any, data: any) => { if (data.meta === 'table') { this.props.actions.addTable( this.props.queryEditor, @@ -163,7 +180,7 @@ class AceEditorWrapper extends React.PureComponent { }); callback(null, words); } - setAutoCompleter(props) { + setAutoCompleter(props: Props) { // Loading schema, table and column names as auto-completable words const schemas = props.schemas || []; const schemaWords = schemas.map(s => ({ @@ -223,7 +240,7 @@ class AceEditorWrapper extends React.PureComponent { const validationResult = this.props.queryEditor.validationResult; const resultIsReady = validationResult && validationResult.completed; if (resultIsReady && validationResult.errors.length > 0) { - const errors = validationResult.errors.map(err => ({ + const errors = validationResult.errors.map((err: any) => ({ type: 'error', row: err.line_number - 1, column: err.start_column - 1, @@ -244,14 +261,12 @@ class AceEditorWrapper extends React.PureComponent { onChange={this.onChange} width="100%" editorProps={{ $blockScrolling: true }} - enableLiveAutocompletion + enableLiveAutocompletion={this.props.autocomplete} value={this.state.sql} annotations={this.getAceAnnotations()} /> ); } } -AceEditorWrapper.defaultProps = defaultProps; -AceEditorWrapper.propTypes = propTypes; export default AceEditorWrapper; diff --git a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton.jsx b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton.jsx new file mode 100644 index 000000000000..b90d351b3194 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton.jsx @@ -0,0 +1,131 @@ +/** + * 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 { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import Dialog from 'react-bootstrap-dialog'; +import { t } from '@superset-ui/translation'; + +import { exportChart } from '../../explore/exploreUtils'; +import * as actions from '../actions/sqlLab'; +import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger'; +import Button from '../../components/Button'; + +const propTypes = { + actions: PropTypes.object.isRequired, + table: PropTypes.string.isRequired, + schema: PropTypes.string, + dbId: PropTypes.number.isRequired, + errorMessage: PropTypes.string, + templateParams: PropTypes.string, +}; +const defaultProps = { + vizRequest: {}, +}; + +class ExploreCtasResultsButton extends React.PureComponent { + constructor(props) { + super(props); + this.visualize = this.visualize.bind(this); + this.onClick = this.onClick.bind(this); + } + onClick() { + this.visualize(); + } + + buildVizOptions() { + return { + datasourceName: this.props.table, + schema: this.props.schema, + dbId: this.props.dbId, + templateParams: this.props.templateParams, + }; + } + visualize() { + this.props.actions + .createCtasDatasource(this.buildVizOptions()) + .then(data => { + const formData = { + datasource: `${data.table_id}__table`, + metrics: ['count'], + groupby: [], + viz_type: 'table', + since: '100 years ago', + all_columns: [], + row_limit: 1000, + }; + this.props.actions.addInfoToast( + t('Creating a data source and creating a new tab'), + ); + + // open new window for data visualization + exportChart(formData); + }) + .catch(() => { + this.props.actions.addDangerToast( + this.props.errorMessage || t('An error occurred'), + ); + }); + } + render() { + return ( + <> + + { + this.dialog = el; + }} + /> + + ); + } +} +ExploreCtasResultsButton.propTypes = propTypes; +ExploreCtasResultsButton.defaultProps = defaultProps; + +function mapStateToProps({ sqlLab, common }) { + return { + errorMessage: sqlLab.errorMessage, + timeout: common.conf ? common.conf.SUPERSET_WEBSERVER_TIMEOUT : null, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch), + }; +} + +export { ExploreCtasResultsButton }; +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ExploreCtasResultsButton); diff --git a/superset-frontend/src/SqlLab/components/ExploreResultsButton.jsx b/superset-frontend/src/SqlLab/components/ExploreResultsButton.jsx index 260c93366a88..2e8746448cd9 100644 --- a/superset-frontend/src/SqlLab/components/ExploreResultsButton.jsx +++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton.jsx @@ -139,8 +139,8 @@ class ExploreResultsButton extends React.PureComponent { datasource: `${data.table_id}__table`, metrics: [], groupby: [], + time_range: 'No filter', viz_type: 'table', - since: '100 years ago', all_columns: columns.map(c => c.name), row_limit: 1000, }; diff --git a/superset-frontend/src/SqlLab/components/QueryTable.jsx b/superset-frontend/src/SqlLab/components/QueryTable.jsx index 472a5a2a924e..111567119c72 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable.jsx +++ b/superset-frontend/src/SqlLab/components/QueryTable.jsx @@ -94,7 +94,7 @@ class QueryTable extends React.PureComponent { render() { const data = this.props.queries .map(query => { - const q = Object.assign({}, query); + const q = { ...query }; if (q.endDttm) { q.duration = fDuration(q.startDttm, q.endDttm); } diff --git a/superset-frontend/src/SqlLab/components/ResultSet.jsx b/superset-frontend/src/SqlLab/components/ResultSet.jsx index b7655d5fd8f5..f3f0ea89d207 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet.jsx +++ b/superset-frontend/src/SqlLab/components/ResultSet.jsx @@ -23,6 +23,7 @@ import shortid from 'shortid'; import { t } from '@superset-ui/translation'; import Loading from '../../components/Loading'; +import ExploreCtasResultsButton from './ExploreCtasResultsButton'; import ExploreResultsButton from './ExploreResultsButton'; import HighlightedSql from './HighlightedSql'; import FilterableTable from '../../components/FilterableTable/FilterableTable'; @@ -101,13 +102,13 @@ export default class ResultSet extends React.PureComponent { clearQueryResults(query) { this.props.actions.clearQueryResults(query); } - popSelectStar() { + popSelectStar(tmpSchema, tmpTable) { const qe = { id: shortid.generate(), - title: this.props.query.tempTable, + title: tmpTable, autorun: false, dbId: this.props.query.dbId, - sql: `SELECT * FROM ${this.props.query.tempTable}`, + sql: `SELECT * FROM ${tmpSchema}.${tmpTable}`, }; this.props.actions.addQueryEditor(qe); } @@ -142,48 +143,42 @@ export default class ResultSet extends React.PureComponent { } return (
-
-
- - {this.props.visualize && ( - - )} - {this.props.csv && ( - - )} +
+ {this.props.visualize && ( + + )} + {this.props.csv && ( + + )} - - {t('Clipboard')} - - } - /> - -
-
- {this.props.search && ( - - )} -
+ + {t('Clipboard')} + + } + />
+ {this.props.search && ( + + )}
); } @@ -216,18 +211,38 @@ export default class ResultSet extends React.PureComponent { ); } else if (query.state === 'success' && query.ctas) { + // Async queries + let tmpSchema = query.tempSchema; + let tmpTable = query.tempTableName; + // Sync queries, query.results.query contains the source of truth for them. + if (query.results && query.results.query) { + tmpTable = query.results.query.tempTable; + tmpSchema = query.results.query.tempSchema; + } return (
- {t('Table')} [{query.tempTable}] {t('was created')}{' '} -   - + {t('Table')} [ + + {tmpSchema}.{tmpTable} + + ] {t('was created')}   + + + +
); diff --git a/superset-frontend/src/SqlLab/components/SqlEditor.jsx b/superset-frontend/src/SqlLab/components/SqlEditor.jsx index 138093f9f6c0..11edbf7c4d81 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor.jsx @@ -20,6 +20,7 @@ import React from 'react'; import { CSSTransition } from 'react-transition-group'; import PropTypes from 'prop-types'; import { + Checkbox, FormGroup, InputGroup, Form, @@ -93,6 +94,7 @@ class SqlEditor extends React.PureComponent { northPercent: props.queryEditor.northPercent || INITIAL_NORTH_PERCENT, southPercent: props.queryEditor.southPercent || INITIAL_SOUTH_PERCENT, sql: props.queryEditor.sql, + autocompleteEnabled: true, }; this.sqlEditorRef = React.createRef(); this.northPaneRef = React.createRef(); @@ -245,6 +247,9 @@ class SqlEditor extends React.PureComponent { handleWindowResize() { this.setState({ height: this.getSqlEditorHeight() }); } + handleToggleAutocompleteEnabled = () => { + this.setState({ autocompleteEnabled: !this.state.autocompleteEnabled }); + }; elementStyle(dimension, elementSize, gutterSize) { return { [dimension]: `calc(${elementSize}% - ${gutterSize + @@ -337,6 +342,7 @@ class SqlEditor extends React.PureComponent {
+ + + {t('Autocomplete')} + + { diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx index b47d7921f05f..b4170b28b1d3 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx @@ -39,6 +39,7 @@ const propTypes = { databases: PropTypes.object.isRequired, queries: PropTypes.object.isRequired, queryEditors: PropTypes.array, + requestedQuery: PropTypes.object, tabHistory: PropTypes.array.isRequired, tables: PropTypes.array.isRequired, offline: PropTypes.bool, @@ -48,6 +49,7 @@ const propTypes = { const defaultProps = { queryEditors: [], offline: false, + requestedQuery: null, saveQueryWarning: null, scheduleQueryWarning: null, }; @@ -99,7 +101,12 @@ class TabbedSqlEditors extends React.PureComponent { }); } - const query = URI(window.location).search(true); + // merge post form data with GET search params + const query = { + ...this.props.requestedQuery, + ...URI(window.location).search(true), + }; + // Popping a new tab based on the querystring if (query.id || query.sql || query.savedQueryId || query.datasourceKey) { if (query.id) { @@ -374,7 +381,7 @@ class TabbedSqlEditors extends React.PureComponent { TabbedSqlEditors.propTypes = propTypes; TabbedSqlEditors.defaultProps = defaultProps; -function mapStateToProps({ sqlLab, common }) { +function mapStateToProps({ sqlLab, common, requestedQuery }) { return { databases: sqlLab.databases, queryEditors: sqlLab.queryEditors, @@ -388,6 +395,7 @@ function mapStateToProps({ sqlLab, common }) { maxRow: common.conf.SQL_MAX_ROW, saveQueryWarning: common.conf.SQLLAB_SAVE_WARNING_MESSAGE, scheduleQueryWarning: common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE, + requestedQuery, }; } function mapDispatchToProps(dispatch) { diff --git a/superset-frontend/src/SqlLab/main.less b/superset-frontend/src/SqlLab/main.less index 230dbcf2a11f..19cd5f87489c 100644 --- a/superset-frontend/src/SqlLab/main.less +++ b/superset-frontend/src/SqlLab/main.less @@ -359,7 +359,21 @@ div.tablePopover { } .ResultSetControls { + display: flex; + justify-content: space-between; padding: 8px 0; + position: fixed; +} + +.ResultSetButtons { + display: grid; + grid-auto-flow: column; + grid-gap: 4px; + padding-right: 8px; +} + +.filterable-table-container { + margin-top: 48px; } .ace_editor { diff --git a/superset-frontend/src/SqlLab/reducers/getInitialState.js b/superset-frontend/src/SqlLab/reducers/getInitialState.js index e04f23b93cc0..fd3c8ae747d0 100644 --- a/superset-frontend/src/SqlLab/reducers/getInitialState.js +++ b/superset-frontend/src/SqlLab/reducers/getInitialState.js @@ -19,8 +19,16 @@ import { t } from '@superset-ui/translation'; import getToastsFromPyFlashMessages from '../../messageToasts/utils/getToastsFromPyFlashMessages'; -export default function getInitialState({ defaultDbId, ...restBootstrapData }) { - /* +export default function getInitialState({ + defaultDbId, + common, + active_tab: activeTab, + tab_state_ids: tabStateIds = [], + databases, + queries: queries_, + requested_query: requestedQuery, +}) { + /** * Before YYYY-MM-DD, the state for SQL Lab was stored exclusively in the * browser's localStorage. The feature flag `SQLLAB_BACKEND_PERSISTENCE` * moves the state to the backend instead, migrating it from local storage. @@ -39,7 +47,7 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) { autorun: false, templateParams: null, dbId: defaultDbId, - queryLimit: restBootstrapData.common.conf.DEFAULT_SQLLAB_LIMIT, + queryLimit: common.conf.DEFAULT_SQLLAB_LIMIT, validationResult: { id: null, errors: [], @@ -52,11 +60,11 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) { }, }; - /* Load state from the backend. This will be empty if the feature flag + /** + * Load state from the backend. This will be empty if the feature flag * `SQLLAB_BACKEND_PERSISTENCE` is off. */ - const activeTab = restBootstrapData.active_tab; - restBootstrapData.tab_state_ids.forEach(({ id, label }) => { + tabStateIds.forEach(({ id, label }) => { let queryEditor; if (activeTab && activeTab.id === id) { queryEditor = { @@ -92,7 +100,6 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) { }); const tabHistory = activeTab ? [activeTab.id.toString()] : []; - const tables = []; if (activeTab) { activeTab.table_schemas @@ -126,9 +133,10 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) { }); } - const { databases, queries } = restBootstrapData; + const queries = { ...queries_ }; - /* If the `SQLLAB_BACKEND_PERSISTENCE` feature flag is off, or if the user + /** + * If the `SQLLAB_BACKEND_PERSISTENCE` feature flag is off, or if the user * hasn't used SQL Lab after it has been turned on, the state will be stored * in the browser's local storage. */ @@ -173,13 +181,14 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) { tables, queriesLastUpdate: Date.now(), }, + requestedQuery, messageToasts: getToastsFromPyFlashMessages( - (restBootstrapData.common || {}).flash_messages || [], + (common || {}).flash_messages || [], ), localStorageUsageInKilobytes: 0, common: { - flash_messages: restBootstrapData.common.flash_messages, - conf: restBootstrapData.common.conf, + flash_messages: common.flash_messages, + conf: common.conf, }, }; } diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.js b/superset-frontend/src/SqlLab/reducers/sqlLab.js index d751917e9f4e..3dbd45fe079f 100644 --- a/superset-frontend/src/SqlLab/reducers/sqlLab.js +++ b/superset-frontend/src/SqlLab/reducers/sqlLab.js @@ -36,7 +36,7 @@ export default function sqlLabReducer(state = {}, action) { [actions.ADD_QUERY_EDITOR]() { const tabHistory = state.tabHistory.slice(); tabHistory.push(action.queryEditor.id); - const newState = Object.assign({}, state, { tabHistory }); + const newState = { ...state, tabHistory }; return addToArr(newState, 'queryEditors', action.queryEditor); }, [actions.QUERY_EDITOR_SAVED]() { @@ -102,19 +102,19 @@ export default function sqlLabReducer(state = {}, action) { table => table.queryEditorId !== action.queryEditor.id, ); - newState = Object.assign({}, newState, { tabHistory, tables, queries }); + newState = { ...newState, tabHistory, tables, queries }; return newState; }, [actions.REMOVE_QUERY]() { - const newQueries = Object.assign({}, state.queries); + const newQueries = { ...state.queries }; delete newQueries[action.query.id]; - return Object.assign({}, state, { queries: newQueries }); + return { ...state, queries: newQueries }; }, [actions.RESET_STATE]() { - return Object.assign({}, getInitialState()); + return { ...getInitialState() }; }, [actions.MERGE_TABLE]() { - const at = Object.assign({}, action.table); + const at = { ...action.table }; let existingTable; state.tables.forEach(xt => { if ( @@ -146,32 +146,31 @@ export default function sqlLabReducer(state = {}, action) { return alterInArr(state, 'tables', action.table, { expanded: true }); }, [actions.REMOVE_DATA_PREVIEW]() { - const queries = Object.assign({}, state.queries); + const queries = { ...state.queries }; delete queries[action.table.dataPreviewQueryId]; const newState = alterInArr(state, 'tables', action.table, { dataPreviewQueryId: null, }); - return Object.assign({}, newState, { queries }); + return { ...newState, queries }; }, [actions.CHANGE_DATA_PREVIEW_ID]() { - const queries = Object.assign({}, state.queries); + const queries = { ...state.queries }; delete queries[action.oldQueryId]; const newTables = []; state.tables.forEach(xt => { if (xt.dataPreviewQueryId === action.oldQueryId) { - newTables.push( - Object.assign({}, xt, { dataPreviewQueryId: action.newQuery.id }), - ); + newTables.push({ ...xt, dataPreviewQueryId: action.newQuery.id }); } else { newTables.push(xt); } }); - return Object.assign({}, state, { + return { + ...state, queries, tables: newTables, activeSouthPaneTab: action.newQuery.id, - }); + }; }, [actions.COLLAPSE_TABLE]() { return alterInArr(state, 'tables', action.table, { expanded: false }); @@ -180,7 +179,7 @@ export default function sqlLabReducer(state = {}, action) { return removeFromArr(state, 'tables', action.table); }, [actions.START_QUERY_VALIDATION]() { - let newState = Object.assign({}, state); + let newState = { ...state }; const sqlEditor = { id: action.query.sqlEditorId }; newState = alterInArr(newState, 'queryEditors', sqlEditor, { validationResult: { @@ -204,7 +203,7 @@ export default function sqlLabReducer(state = {}, action) { return state; } // Otherwise, persist the results on the queryEditor state - let newState = Object.assign({}, state); + let newState = { ...state }; const sqlEditor = { id: action.query.sqlEditorId }; newState = alterInArr(newState, 'queryEditors', sqlEditor, { validationResult: { @@ -228,7 +227,7 @@ export default function sqlLabReducer(state = {}, action) { return state; } // Otherwise, persist the results on the queryEditor state - let newState = Object.assign({}, state); + let newState = { ...state }; const sqlEditor = { id: action.query.sqlEditorId }; newState = alterInArr(newState, 'queryEditors', sqlEditor, { validationResult: { @@ -247,7 +246,7 @@ export default function sqlLabReducer(state = {}, action) { return newState; }, [actions.COST_ESTIMATE_STARTED]() { - let newState = Object.assign({}, state); + let newState = { ...state }; const sqlEditor = { id: action.query.sqlEditorId }; newState = alterInArr(newState, 'queryEditors', sqlEditor, { queryCostEstimate: { @@ -259,7 +258,7 @@ export default function sqlLabReducer(state = {}, action) { return newState; }, [actions.COST_ESTIMATE_RETURNED]() { - let newState = Object.assign({}, state); + let newState = { ...state }; const sqlEditor = { id: action.query.sqlEditorId }; newState = alterInArr(newState, 'queryEditors', sqlEditor, { queryCostEstimate: { @@ -271,7 +270,7 @@ export default function sqlLabReducer(state = {}, action) { return newState; }, [actions.COST_ESTIMATE_FAILED]() { - let newState = Object.assign({}, state); + let newState = { ...state }; const sqlEditor = { id: action.query.sqlEditorId }; newState = alterInArr(newState, 'queryEditors', sqlEditor, { queryCostEstimate: { @@ -283,23 +282,18 @@ export default function sqlLabReducer(state = {}, action) { return newState; }, [actions.START_QUERY]() { - let newState = Object.assign({}, state); + let newState = { ...state }; if (action.query.sqlEditorId) { const qe = getFromArr(state.queryEditors, action.query.sqlEditorId); if (qe.latestQueryId && state.queries[qe.latestQueryId]) { - const newResults = Object.assign( - {}, - state.queries[qe.latestQueryId].results, - { - data: [], - query: null, - }, - ); - const q = Object.assign({}, state.queries[qe.latestQueryId], { - results: newResults, - }); - const queries = Object.assign({}, state.queries, { [q.id]: q }); - newState = Object.assign({}, state, { queries }); + const newResults = { + ...state.queries[qe.latestQueryId].results, + data: [], + query: null, + }; + const q = { ...state.queries[qe.latestQueryId], results: newResults }; + const queries = { ...state.queries, [q.id]: q }; + newState = { ...state, queries }; } } else { newState.activeSouthPaneTab = action.query.id; @@ -317,7 +311,7 @@ export default function sqlLabReducer(state = {}, action) { }); }, [actions.CLEAR_QUERY_RESULTS]() { - const newResults = Object.assign({}, action.query.results); + const newResults = { ...action.query.results }; newResults.data = []; return alterInObject(state, 'queries', action.query, { results: newResults, @@ -365,7 +359,7 @@ export default function sqlLabReducer(state = {}, action) { ) { const tabHistory = state.tabHistory.slice(); tabHistory.push(action.queryEditor.id); - return Object.assign({}, state, { tabHistory }); + return { ...state, tabHistory }; } return state; }, @@ -378,7 +372,7 @@ export default function sqlLabReducer(state = {}, action) { return extendArr(state, 'tables', action.tables); }, [actions.SET_ACTIVE_SOUTHPANE_TAB]() { - return Object.assign({}, state, { activeSouthPaneTab: action.tabId }); + return { ...state, activeSouthPaneTab: action.tabId }; }, [actions.MIGRATE_QUERY_EDITOR]() { // remove migrated query editor from localStorage @@ -421,7 +415,7 @@ export default function sqlLabReducer(state = {}, action) { tabId => tabId !== action.oldId, ); tabHistory.push(action.newId); - return Object.assign({}, state, { tabHistory }); + return { ...state, tabHistory }; }, [actions.MIGRATE_QUERY]() { const query = { @@ -429,8 +423,8 @@ export default function sqlLabReducer(state = {}, action) { // point query to migrated query editor sqlEditorId: action.queryEditorId, }; - const queries = Object.assign({}, state.queries, { [query.id]: query }); - return Object.assign({}, state, { queries }); + const queries = { ...state.queries, [query.id]: query }; + return { ...state, queries }; }, [actions.QUERY_EDITOR_SETDB]() { return alterInArr(state, 'queryEditors', action.queryEditor, { @@ -493,10 +487,10 @@ export default function sqlLabReducer(state = {}, action) { action.databases.forEach(db => { databases[db.id] = db; }); - return Object.assign({}, state, { databases }); + return { ...state, databases }; }, [actions.REFRESH_QUERIES]() { - let newQueries = Object.assign({}, state.queries); + let newQueries = { ...state.queries }; // Fetch the updates to the queries present in the store. let change = false; let queriesLastUpdate = state.queriesLastUpdate; @@ -510,39 +504,31 @@ export default function sqlLabReducer(state = {}, action) { if (changedQuery.changedOn > queriesLastUpdate) { queriesLastUpdate = changedQuery.changedOn; } - newQueries[id] = Object.assign({}, state.queries[id], changedQuery); + newQueries[id] = { ...state.queries[id], ...changedQuery }; change = true; } } if (!change) { newQueries = state.queries; } - return Object.assign({}, state, { - queries: newQueries, - queriesLastUpdate, - }); + return { ...state, queries: newQueries, queriesLastUpdate }; }, [actions.SET_USER_OFFLINE]() { - return Object.assign({}, state, { offline: action.offline }); + return { ...state, offline: action.offline }; }, [actions.CREATE_DATASOURCE_STARTED]() { - return Object.assign({}, state, { - isDatasourceLoading: true, - errorMessage: null, - }); + return { ...state, isDatasourceLoading: true, errorMessage: null }; }, [actions.CREATE_DATASOURCE_SUCCESS]() { - return Object.assign({}, state, { + return { + ...state, isDatasourceLoading: false, errorMessage: null, datasource: action.datasource, - }); + }; }, [actions.CREATE_DATASOURCE_FAILED]() { - return Object.assign({}, state, { - isDatasourceLoading: false, - errorMessage: action.err, - }); + return { ...state, isDatasourceLoading: false, errorMessage: action.err }; }, }; if (action.type in actionHandlers) { diff --git a/superset-frontend/src/addSlice/AddSliceContainer.jsx b/superset-frontend/src/addSlice/AddSliceContainer.jsx index 106d33af9ad7..f304d6695b8c 100644 --- a/superset-frontend/src/addSlice/AddSliceContainer.jsx +++ b/superset-frontend/src/addSlice/AddSliceContainer.jsx @@ -80,53 +80,58 @@ export default class AddSliceContainer extends React.PureComponent { render() { return (
- {t('Create a new chart')}}> -
-

{t('Choose a datasource')}

-
- +
+

+ {t( + 'If the datasource you are looking for is not ' + + 'available in the list, ' + + 'follow the instructions on the how to add it on the ', + )} + + {t('Superset tutorial')} + +

+
+
+
+

{t('Choose a visualization type')}

+
-

- {t( - 'If the datasource you are looking for is not ' + - 'available in the list, ' + - 'follow the instructions on the how to add it on the ', - )} - - {t('Superset tutorial')} - -

-
-
-
-

{t('Choose a visualization type')}

- -
-
-
- -
-
+
+
+ +
+
+
); diff --git a/superset-frontend/src/chart/Chart.jsx b/superset-frontend/src/chart/Chart.jsx index 227b7af86b47..044f0d83fe82 100644 --- a/superset-frontend/src/chart/Chart.jsx +++ b/superset-frontend/src/chart/Chart.jsx @@ -74,6 +74,7 @@ const defaultProps = { setControlValue() {}, triggerRender: false, dashboardId: null, + chartStackTrace: null, }; class Chart extends React.PureComponent { diff --git a/superset-frontend/src/chart/ChartRenderer.jsx b/superset-frontend/src/chart/ChartRenderer.jsx index 304644ede50f..a5cb332551d6 100644 --- a/superset-frontend/src/chart/ChartRenderer.jsx +++ b/superset-frontend/src/chart/ChartRenderer.jsx @@ -21,7 +21,6 @@ import { snakeCase } from 'lodash'; import PropTypes from 'prop-types'; import React from 'react'; import { SuperChart } from '@superset-ui/chart'; -import { Tooltip } from 'react-bootstrap'; import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger/LogUtils'; const propTypes = { @@ -62,11 +61,8 @@ const defaultProps = { class ChartRenderer extends React.Component { constructor(props) { super(props); - this.state = {}; - this.hasQueryResponseChange = false; - this.setTooltip = this.setTooltip.bind(this); this.handleAddFilter = this.handleAddFilter.bind(this); this.handleRenderSuccess = this.handleRenderSuccess.bind(this); this.handleRenderFailure = this.handleRenderFailure.bind(this); @@ -76,13 +72,12 @@ class ChartRenderer extends React.Component { onAddFilter: this.handleAddFilter, onError: this.handleRenderFailure, setControlValue: this.handleSetControlValue, - setTooltip: this.setTooltip, onFilterMenuOpen: this.props.onFilterMenuOpen, onFilterMenuClose: this.props.onFilterMenuClose, }; } - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate(nextProps) { const resultsReady = nextProps.queryResponse && ['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 && @@ -98,9 +93,9 @@ class ChartRenderer extends React.Component { nextProps.annotationData !== this.props.annotationData || nextProps.height !== this.props.height || nextProps.width !== this.props.width || - nextState.tooltip !== this.state.tooltip || nextProps.triggerRender || - nextProps.formData.color_scheme !== this.props.formData.color_scheme + nextProps.formData.color_scheme !== this.props.formData.color_scheme || + nextProps.cacheBusterProp !== this.props.cacheBusterProp ) { return true; } @@ -108,10 +103,6 @@ class ChartRenderer extends React.Component { return false; } - setTooltip(tooltip) { - this.setState({ tooltip }); - } - handleAddFilter(col, vals, merge = true, refresh = true) { this.props.addFilter(col, vals, merge, refresh); } @@ -164,33 +155,6 @@ class ChartRenderer extends React.Component { } } - renderTooltip() { - const { tooltip } = this.state; - if (tooltip && tooltip.content) { - return ( - - {typeof tooltip.content === 'string' ? ( -
- ) : ( - tooltip.content - )} - - ); - } - return null; - } - render() { const { chartAlert, @@ -233,25 +197,25 @@ class ChartRenderer extends React.Component { : snakeCaseVizType; return ( - <> - {this.renderTooltip()} - - + ); } } diff --git a/superset-frontend/src/chart/chartAction.js b/superset-frontend/src/chart/chartAction.js index c7a125f63163..0d6b03b1ea54 100644 --- a/superset-frontend/src/chart/chartAction.js +++ b/superset-frontend/src/chart/chartAction.js @@ -25,6 +25,7 @@ import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { getExploreUrlAndPayload, getAnnotationJsonUrl, + postForm, } from '../explore/exploreUtils'; import { requiresQuery, @@ -358,14 +359,12 @@ export function redirectSQLLab(formData) { postPayload: { form_data: formData }, }) .then(({ json }) => { - const redirectUrl = new URL(window.location); - redirectUrl.pathname = '/superset/sqllab'; - for (const key of redirectUrl.searchParams.keys()) { - redirectUrl.searchParams.delete(key); - } - redirectUrl.searchParams.set('datasourceKey', formData.datasource); - redirectUrl.searchParams.set('sql', json.query); - window.open(redirectUrl.href, '_blank'); + const redirectUrl = '/superset/sqllab'; + const payload = { + datasourceKey: formData.datasource, + sql: json.query, + }; + postForm(redirectUrl, payload); }) .catch(() => dispatch(addDangerToast(t('An error occurred while loading the SQL'))), diff --git a/superset-frontend/src/chart/chartReducer.js b/superset-frontend/src/chart/chartReducer.js index 8ac7f1aefdc2..1409623dabae 100644 --- a/superset-frontend/src/chart/chartReducer.js +++ b/superset-frontend/src/chart/chartReducer.js @@ -49,6 +49,7 @@ export default function chartReducer(charts = {}, action) { chartStatus: 'success', queryResponse: action.queryResponse, chartAlert: null, + chartUpdateEndTime: now(), }; }, [actions.CHART_UPDATE_STARTED](state) { diff --git a/superset-frontend/src/components/AlteredSliceTag.jsx b/superset-frontend/src/components/AlteredSliceTag.jsx index 5f274042f157..dbba032e04b3 100644 --- a/superset-frontend/src/components/AlteredSliceTag.jsx +++ b/superset-frontend/src/components/AlteredSliceTag.jsx @@ -20,9 +20,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Table, Tr, Td, Thead, Th } from 'reactable-arc'; import { isEqual, isEmpty } from 'lodash'; +import { getChartControlPanelRegistry } from '@superset-ui/chart'; +import getControlsForVizType from 'src/utils/getControlsForVizType'; import { t } from '@superset-ui/translation'; import TooltipWrapper from './TooltipWrapper'; -import { controls } from '../explore/controls'; import ModalTrigger from './ModalTrigger'; import { safeStringify } from '../utils/safeStringify'; @@ -52,7 +53,10 @@ export default class AlteredSliceTag extends React.Component { constructor(props) { super(props); const diffs = this.getDiffs(props); - this.state = { diffs, hasDiffs: !isEmpty(diffs) }; + + const controlsMap = getControlsForVizType(this.props.origFormData.viz_type); + + this.state = { diffs, hasDiffs: !isEmpty(diffs), controlsMap }; } UNSAFE_componentWillReceiveProps(newProps) { @@ -69,6 +73,7 @@ export default class AlteredSliceTag extends React.Component { // current form data and the saved form data const ofd = props.origFormData; const cfd = props.currentFormData; + const fdKeys = Object.keys(cfd); const diffs = {}; for (const fdKey of fdKeys) { @@ -98,7 +103,10 @@ export default class AlteredSliceTag extends React.Component { return 'N/A'; } else if (value === null) { return 'null'; - } else if (controls[key] && controls[key].type === 'AdhocFilterControl') { + } else if ( + this.state.controlsMap[key] && + this.state.controlsMap[key].type === 'AdhocFilterControl' + ) { if (!value.length) { return '[]'; } @@ -111,9 +119,15 @@ export default class AlteredSliceTag extends React.Component { return `${v.subject} ${v.operator} ${filterVal}`; }) .join(', '); - } else if (controls[key] && controls[key].type === 'BoundsControl') { + } else if ( + this.state.controlsMap[key] && + this.state.controlsMap[key].type === 'BoundsControl' + ) { return `Min: ${value[0]}, Max: ${value[1]}`; - } else if (controls[key] && controls[key].type === 'CollectionControl') { + } else if ( + this.state.controlsMap[key] && + this.state.controlsMap[key].type === 'CollectionControl' + ) { return value.map(v => safeStringify(v)).join(', '); } else if (typeof value === 'boolean') { return value ? 'true' : 'false'; @@ -133,7 +147,11 @@ export default class AlteredSliceTag extends React.Component { {this.formatValue(diffs[key].before, key)} {this.formatValue(diffs[key].after, key)} diff --git a/superset-frontend/src/components/Button.jsx b/superset-frontend/src/components/Button.jsx index 43fe49bb85ab..80be8498c6e2 100644 --- a/superset-frontend/src/components/Button.jsx +++ b/superset-frontend/src/components/Button.jsx @@ -42,7 +42,7 @@ const defaultProps = { const BUTTON_WRAPPER_STYLE = { display: 'inline-block', cursor: 'not-allowed' }; export default function Button(props) { - const buttonProps = Object.assign({}, props); + const buttonProps = { ...props }; const tooltip = props.tooltip; const placement = props.placement; delete buttonProps.tooltip; diff --git a/superset-frontend/src/components/ListView/Filters.tsx b/superset-frontend/src/components/ListView/Filters.tsx new file mode 100644 index 000000000000..25b2c5b0bd9d --- /dev/null +++ b/superset-frontend/src/components/ListView/Filters.tsx @@ -0,0 +1,191 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState } from 'react'; +import styled from '@emotion/styled'; +import { withTheme } from 'emotion-theming'; + +import StyledSelect, { AsyncStyledSelect } from 'src/components/StyledSelect'; +import SearchInput from 'src/components/SearchInput'; +import { Filter, Filters, FilterValue, InternalFilter } from './types'; + +interface BaseFilter { + Header: string; + initialValue: any; +} +interface SelectFilterProps extends BaseFilter { + onSelect: (selected: any) => any; + selects: Filter['selects']; + emptyLabel?: string; + fetchSelects?: Filter['fetchSelects']; +} + +const FilterContainer = styled.div` + display: inline; + margin-right: 8px; +`; + +const Title = styled.span` + font-weight: bold; +`; + +const CLEAR_SELECT_FILTER_VALUE = 'CLEAR_SELECT_FILTER_VALUE'; + +function SelectFilter({ + Header, + selects = [], + emptyLabel = 'None', + initialValue, + onSelect, + fetchSelects, +}: SelectFilterProps) { + const clearFilterSelect = { + label: emptyLabel, + value: CLEAR_SELECT_FILTER_VALUE, + }; + + const options = React.useMemo(() => [clearFilterSelect, ...selects], [ + emptyLabel, + selects, + ]); + + const [value, setValue] = useState( + typeof initialValue === 'undefined' + ? clearFilterSelect.value + : initialValue, + ); + const onChange = (selected: { label: string; value: any } | null) => { + if (selected === null) return; + setValue(selected.value); + onSelect( + selected.value === CLEAR_SELECT_FILTER_VALUE ? undefined : selected.value, + ); + }; + const fetchAndFormatSelects = async () => { + if (!fetchSelects) return { options: [clearFilterSelect] }; + const selectValues = await fetchSelects(); + return { options: [clearFilterSelect, ...selectValues] }; + }; + + return ( + + {Header}: + {fetchSelects ? ( + + ) : ( + + )} + + ); +} + +interface SearchHeaderProps extends BaseFilter { + Header: string; + onSubmit: (val: string) => void; +} + +function SearchFilter({ Header, initialValue, onSubmit }: SearchHeaderProps) { + const [value, setValue] = useState(initialValue || ''); + const handleSubmit = () => onSubmit(value); + + return ( + + { + setValue(e.currentTarget.value); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + handleSubmit(); + } + }} + onBlur={handleSubmit} + /> + + ); +} + +interface UIFiltersProps { + filters: Filters; + internalFilters: InternalFilter[]; + updateFilterValue: (id: number, value: FilterValue['value']) => void; +} + +const FilterWrapper = styled.div` + padding: 24px 16px 8px; +`; + +function UIFilters({ + filters, + internalFilters = [], + updateFilterValue, +}: UIFiltersProps) { + return ( + + {filters.map( + ({ Header, input, selects, unfilteredLabel, fetchSelects }, index) => { + const initialValue = + internalFilters[index] && internalFilters[index].value; + if (input === 'select') { + return ( + updateFilterValue(index, value)} + /> + ); + } + if (input === 'search') { + return ( + updateFilterValue(index, value)} + /> + ); + } + return null; + }, + )} + + ); +} + +export default withTheme(UIFilters); diff --git a/superset-frontend/src/components/ListView/LegacyFilters.tsx b/superset-frontend/src/components/ListView/LegacyFilters.tsx new file mode 100644 index 000000000000..6c493bc63669 --- /dev/null +++ b/superset-frontend/src/components/ListView/LegacyFilters.tsx @@ -0,0 +1,199 @@ +/** + * 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 { t } from '@superset-ui/translation'; +import React, { Dispatch, SetStateAction } from 'react'; +import { + Button, + Col, + DropdownButton, + FormControl, + MenuItem, + Row, + // @ts-ignore +} from 'react-bootstrap'; +// @ts-ignore +import SelectComponent from 'react-select'; +// @ts-ignore +import VirtualizedSelect from 'react-virtualized-select'; +import { Filters, InternalFilter, Select } from './types'; +import { extractInputValue, getDefaultFilterOperator } from './utils'; + +export const FilterMenu = ({ + filters, + internalFilters, + setInternalFilters, +}: { + filters: Filters; + internalFilters: InternalFilter[]; + setInternalFilters: Dispatch>; +}) => ( +
+ + + {' '} + {t('Filter List')} + + } + > + {filters + .map(({ id, Header }) => ({ + Header, + id, + value: undefined, + })) + .map(ft => ( + + setInternalFilters([...internalFilters, fltr]) + } + > + {ft.Header} + + ))} + +
+); + +export const FilterInputs = ({ + internalFilters, + filters, + updateInternalFilter, + removeFilterAndApply, + filtersApplied, + applyFilters, +}: { + internalFilters: InternalFilter[]; + filters: Filters; + updateInternalFilter: (i: number, f: object) => void; + removeFilterAndApply: (i: number) => void; + filtersApplied: boolean; + applyFilters: () => void; +}) => ( + <> + {internalFilters.map((ft, i) => { + const filter = filters.find(f => f.id === ft.id); + if (!filter) { + console.error(`could not find filter for ${ft.id}`); + return null; + } + return ( +
+ + + {ft.Header} + + + ) => { + updateInternalFilter(i, { + operator: e.currentTarget.value, + }); + }} + > + {(filter.operators || []).map(({ label, value }: Select) => ( + + ))} + + + + + {filter.input === 'select' && ( + { + updateInternalFilter(i, { + operator: ft.operator || getDefaultFilterOperator(filter), + value: e ? e.map(s => s.value) : e, + }); + }} + /> + )} + {filter.input !== 'select' && ( + ) => { + e.persist(); + updateInternalFilter(i, { + operator: ft.operator || getDefaultFilterOperator(filter), + value: extractInputValue(filter.input, e), + }); + }} + /> + )} + + +
removeFilterAndApply(i)} + > + +
+ +
+
+
+ ); + })} + {internalFilters.length > 0 && ( + <> + + + + + + +
+ + )} + +); diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index aff559e0b20a..2999d7818d96 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -19,12 +19,9 @@ import { t } from '@superset-ui/translation'; import React, { FunctionComponent } from 'react'; import { - Button, Col, DropdownButton, - FormControl, MenuItem, - Pagination, Row, // @ts-ignore } from 'react-bootstrap'; @@ -33,22 +30,14 @@ import SelectComponent from 'react-select'; // @ts-ignore import VirtualizedSelect from 'react-virtualized-select'; import IndeterminateCheckbox from '../IndeterminateCheckbox'; -import './ListViewStyles.less'; import TableCollection from './TableCollection'; -import { - FetchDataConfig, - Filters, - InternalFilter, - Select, - SortColumn, -} from './types'; -import { - convertFilters, - extractInputValue, - ListViewError, - removeFromList, - useListViewState, -} from './utils'; +import Pagination from './Pagination'; +import { FilterMenu, FilterInputs } from './LegacyFilters'; +import FilterControls from './Filters'; +import { FetchDataConfig, Filters, SortColumn } from './types'; +import { ListViewError, useListViewState } from './utils'; + +import './ListViewStyles.less'; interface Props { columns: any[]; @@ -66,6 +55,7 @@ interface Props { name: React.ReactNode; onSelect: (rows: any[]) => any; }>; + useNewUIFilters?: boolean; } const bulkSelectColumnConfig = { @@ -94,6 +84,7 @@ const ListView: FunctionComponent = ({ title = '', filters = [], bulkActions = [], + useNewUIFilters = false, }) => { const { getTableProps, @@ -101,13 +92,12 @@ const ListView: FunctionComponent = ({ headerGroups, rows, prepareRow, - canPreviousPage, - canNextPage, pageCount = 1, gotoPage, - setAllFilters, + removeFilterAndApply, setInternalFilters, updateInternalFilter, + applyFilterValue, applyFilters, filtersApplied, selectedFlatRows, @@ -121,6 +111,7 @@ const ListView: FunctionComponent = ({ fetchData, initialPageSize, initialSort, + initialFilters: useNewUIFilters ? filters : [], }); const filterable = Boolean(filters.length); if (filterable) { @@ -137,161 +128,56 @@ const ListView: FunctionComponent = ({ }); } - const removeFilterAndApply = (index: number) => { - const updated = removeFromList(internalFilters, index); - setInternalFilters(updated); - setAllFilters(convertFilters(updated)); - }; - return (
- {title && filterable && ( -
- - -

{t(title)}

- - {filterable && ( - -
- - - {' '} - {t('Filter List')} - - } - > - {filters - .map(({ id, Header }) => ({ - Header, - id, - })) - .map((ft: InternalFilter) => ( - - setInternalFilters([...internalFilters, fltr]) - } - > - {ft.Header} - - ))} - -
- - )} -
-
- {internalFilters.map((ft, i) => { - const filter = filters.find(f => f.id === ft.id); - if (!filter) { - console.error(`could not find filter for ${ft.id}`); - return null; - } - return ( -
+
+ {!useNewUIFilters && ( + <> + {title && filterable && ( + <> - - {ft.Header} + +

{t(title)}

- - ) => { - updateInternalFilter(i, { - operator: e.currentTarget.value, - }); - }} - > - {filter.operators.map(({ label, value }: Select) => ( - - ))} - - - - - {filter.input === 'select' && ( - { - updateInternalFilter(i, { - operator: ft.operator || filter.operators[0].value, - value: e ? e.map(s => s.value) : e, - }); - }} + {filterable && ( + + - )} - {filter.input !== 'select' && ( - ) => { - e.persist(); - updateInternalFilter(i, { - operator: ft.operator || filter.operators[0].value, - value: extractInputValue(filter.input, e), - }); - }} - /> - )} - - -
removeFilterAndApply(i)} - > - -
- + + )}
-
-
- ); - })} - {internalFilters.length > 0 && ( - <> - - - - - - -
- - )} -
- )} +
+ + + )} + + )} + {useNewUIFilters && ( + <> + + +

{t(title)}

+ +
+
+ + + )} +
= ({ 1} - next={canNextPage} - last={pageIndex < pageCount - 2} - items={pageCount} - activePage={pageIndex + 1} - ellipsis - boundaryLinks - maxButtons={5} - onSelect={(p: number) => gotoPage(p - 1)} + totalPages={pageCount || 0} + currentPage={pageCount ? pageIndex + 1 : 0} + onChange={(p: number) => gotoPage(p - 1)} + hideFirstAndLastPageLinks /> diff --git a/superset-frontend/src/components/ListView/ListViewStyles.less b/superset-frontend/src/components/ListView/ListViewStyles.less index 20b27730516f..2a510c6c6da4 100644 --- a/superset-frontend/src/components/ListView/ListViewStyles.less +++ b/superset-frontend/src/components/ListView/ListViewStyles.less @@ -60,6 +60,10 @@ .action-button { margin: 0 8px; } + + .table-cell { + word-break: break-all; + } } @keyframes shimmer { diff --git a/superset-frontend/src/components/ListView/Pagination.tsx b/superset-frontend/src/components/ListView/Pagination.tsx new file mode 100644 index 000000000000..03b8663dba27 --- /dev/null +++ b/superset-frontend/src/components/ListView/Pagination.tsx @@ -0,0 +1,53 @@ +/** + * 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'; +// @ts-ignore +import { Pagination } from 'react-bootstrap'; +import { + createUltimatePagination, + ITEM_TYPES, +} from 'react-ultimate-pagination'; + +const ListViewPagination = createUltimatePagination({ + WrapperComponent: Pagination, + itemTypeToComponent: { + [ITEM_TYPES.PAGE]: ({ value, isActive, onClick }) => ( + + {value} + + ), + [ITEM_TYPES.ELLIPSIS]: ({ isActive, onClick }) => ( + + ), + [ITEM_TYPES.FIRST_PAGE_LINK]: ({ isActive, onClick }) => ( + + ), + [ITEM_TYPES.PREVIOUS_PAGE_LINK]: ({ isActive, onClick }) => ( + + ), + [ITEM_TYPES.NEXT_PAGE_LINK]: ({ isActive, onClick }) => ( + + ), + [ITEM_TYPES.LAST_PAGE_LINK]: ({ isActive, onClick }) => ( + + ), + }, +}); + +export default ListViewPagination; diff --git a/superset-frontend/src/components/ListView/TableCollection.tsx b/superset-frontend/src/components/ListView/TableCollection.tsx index 126a0570be5b..863e655ce6df 100644 --- a/superset-frontend/src/components/ListView/TableCollection.tsx +++ b/superset-frontend/src/components/ListView/TableCollection.tsx @@ -85,7 +85,11 @@ export default function TableCollection({ const columnCellProps = cell.column.cellProps || {}; return ( - + {cell.render('Cell')} ); diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index 294192db0c3c..76acae3b7a3e 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -31,9 +31,13 @@ export interface Select { export interface Filter { Header: string; id: string; - operators: Select[]; - input?: 'text' | 'textarea' | 'select' | 'checkbox'; + operators?: Select[]; + operator?: string; + input?: 'text' | 'textarea' | 'select' | 'checkbox' | 'search'; + unfilteredLabel?: string; selects?: Select[]; + onFilterOpen?: () => void; + fetchSelects?: () => Promise; } export type Filters = Filter[]; @@ -41,7 +45,13 @@ export type Filters = Filter[]; export interface FilterValue { id: string; operator?: string; - value: string | boolean | number; + value: + | string + | boolean + | number + | null + | undefined + | { datasource_id: number; datasource_type: string }; } export interface FetchDataConfig { @@ -52,7 +62,7 @@ export interface FetchDataConfig { } export interface InternalFilter extends FilterValue { - Header: string; + Header?: string; } export interface FilterOperatorMap { diff --git a/superset-frontend/src/components/ListView/utils.ts b/superset-frontend/src/components/ListView/utils.ts index d94703a301cc..6bc643a9edad 100644 --- a/superset-frontend/src/components/ListView/utils.ts +++ b/superset-frontend/src/components/ListView/utils.ts @@ -33,7 +33,13 @@ import { useQueryParams, } from 'use-query-params'; -import { FetchDataConfig, InternalFilter, SortColumn } from './types'; +import { + FetchDataConfig, + Filter, + FilterValue, + InternalFilter, + SortColumn, +} from './types'; export class ListViewError extends Error { name = 'ListViewError'; @@ -55,17 +61,22 @@ function updateInList(list: any[], index: number, update: any): any[] { ]; } +function mergeCreateFilterValues(list: Filter[], updateList: FilterValue[]) { + return list.map(({ id, operator }) => { + const update = updateList.find(obj => obj.id === id); + + return { id, operator, value: update?.value }; + }); +} + // convert filters from UI objects to data objects -export function convertFilters(fts: InternalFilter[]) { +export function convertFilters(fts: InternalFilter[]): FilterValue[] { return fts - .filter((ft: InternalFilter) => ft.value) - .map(ft => ({ operator: ft.operator, ...ft })); + .filter(f => typeof f.value !== 'undefined') + .map(({ value, operator, id }) => ({ value, operator, id })); } -export function extractInputValue( - inputType: 'text' | 'textarea' | 'checkbox' | 'select' | undefined, - event: any, -) { +export function extractInputValue(inputType: Filter['input'], event: any) { if (!inputType || inputType === 'text') { return event.currentTarget.value; } @@ -76,6 +87,13 @@ export function extractInputValue( return null; } +export function getDefaultFilterOperator(filter: Filter): string { + if (filter?.operator) return filter.operator; + if (filter?.operators?.length) { + return filter.operators[0].value; + } + return ''; +} interface UseListViewConfig { fetchData: (conf: FetchDataConfig) => any; columns: any[]; @@ -84,6 +102,7 @@ interface UseListViewConfig { initialPageSize: number; initialSort?: SortColumn[]; bulkSelectMode?: boolean; + initialFilters?: Filter[]; bulkSelectColumnConfig?: { id: string; Header: (conf: any) => React.ReactNode; @@ -97,6 +116,7 @@ export function useListViewState({ data, count, initialPageSize, + initialFilters = [], initialSort = [], bulkSelectMode = false, bulkSelectColumnConfig, @@ -123,10 +143,13 @@ export function useListViewState({ sortBy: initialSortBy, }; - const columnsWithSelect = useMemo( - () => (bulkSelectMode ? [bulkSelectColumnConfig, ...columns] : columns), - [bulkSelectMode, columns], - ); + const columnsWithSelect = useMemo(() => { + // add exact filter type so filters with falsey values are not filtered out + const columnsWithFilter = columns.map(f => ({ ...f, filter: 'exact' })); + return bulkSelectMode + ? [bulkSelectColumnConfig, ...columnsWithFilter] + : columnsWithFilter; + }, [bulkSelectMode, columns]); const { getTableProps, @@ -165,6 +188,14 @@ export function useListViewState({ query.filters || [], ); + useEffect(() => { + if (initialFilters.length) { + setInternalFilters( + mergeCreateFilterValues(initialFilters, query.filters), + ); + } + }, [initialFilters]); + useEffect(() => { const queryParams: any = { filters: internalFilters, @@ -175,22 +206,41 @@ export function useListViewState({ queryParams.sortOrder = sortBy[0].desc ? 'desc' : 'asc'; } setQuery(queryParams); - fetchData({ pageIndex, pageSize, sortBy, filters }); }, [fetchData, pageIndex, pageSize, sortBy, filters]); const filtersApplied = internalFilters.every( ({ id, value, operator }, index) => id && - filters[index] && - filters[index].id === id && - filters[index].value === value && + filters[index]?.id === id && + filters[index]?.value === value && // @ts-ignore - filters[index].operator === operator, + filters[index]?.operator === operator, ); + const updateInternalFilter = (index: number, update: object) => + setInternalFilters(updateInList(internalFilters, index, update)); + + const applyFilterValue = (index: number, value: any) => { + // skip redunundant updates + if (internalFilters[index].value === value) { + return; + } + const update = { ...internalFilters[index], value }; + const updatedFilters = updateInList(internalFilters, index, update); + setInternalFilters(updatedFilters); + setAllFilters(convertFilters(updatedFilters)); + }; + + const removeFilterAndApply = (index: number) => { + const updated = removeFromList(internalFilters, index); + setInternalFilters(updated); + setAllFilters(convertFilters(updated)); + }; + return { applyFilters: () => setAllFilters(convertFilters(internalFilters)), + removeFilterAndApply, canNextPage, canPreviousPage, filtersApplied, @@ -205,7 +255,7 @@ export function useListViewState({ setAllFilters, setInternalFilters, state: { pageIndex, pageSize, sortBy, filters, internalFilters }, - updateInternalFilter: (index: number, update: object) => - setInternalFilters(updateInList(internalFilters, index, update)), + updateInternalFilter, + applyFilterValue, }; } diff --git a/superset-frontend/src/explore/validators.js b/superset-frontend/src/components/SearchInput.tsx similarity index 51% rename from superset-frontend/src/explore/validators.js rename to superset-frontend/src/components/SearchInput.tsx index 5cbdb21033c6..dc4e74451b43 100644 --- a/superset-frontend/src/explore/validators.js +++ b/superset-frontend/src/components/SearchInput.tsx @@ -16,36 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -/* Reusable validator functions used in controls definitions - * - * validator functions receive the v and the configuration of the control - * as arguments and return something that evals to false if v is valid, - * and an error message if not valid. - * */ -import { t } from '@superset-ui/translation'; - -export function numeric(v) { - if (v && isNaN(v)) { - return t('is expected to be a number'); - } - return false; -} - -export function integer(v) { - if (v && (isNaN(v) || parseInt(v, 10) !== +v)) { - return t('is expected to be an integer'); - } - return false; -} +import styled from '@emotion/styled'; -export function nonEmpty(v) { - if ( - v === null || - v === undefined || - v === '' || - (Array.isArray(v) && v.length === 0) - ) { - return t('cannot be empty'); - } - return false; -} +export default styled.input` + background-color: #fff; + background-image: none; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + padding: 4px 8px; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +`; diff --git a/superset-frontend/src/components/StyledSelect.tsx b/superset-frontend/src/components/StyledSelect.tsx new file mode 100644 index 000000000000..79d9151fc66d --- /dev/null +++ b/superset-frontend/src/components/StyledSelect.tsx @@ -0,0 +1,75 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import styled from '@emotion/styled'; +// @ts-ignore +import Select, { Async } from 'react-select'; + +export default styled(Select)` + display: inline; + &.is-focused:not(.is-open) > .Select-control { + border: none; + box-shadow: none; + } + .Select-control { + display: inline-table; + border: none; + width: 100px; + &:focus, + &:hover { + border: none; + box-shadow: none; + } + + .Select-arrow-zone { + padding-left: 10px; + } + } + .Select-menu-outer { + margin-top: 0; + border-bottom-left-radius: 0; + border-bottom-left-radius: 0; + } +`; + +export const AsyncStyledSelect = styled(Async)` + display: inline; + &.is-focused:not(.is-open) > .Select-control { + border: none; + box-shadow: none; + } + .Select-control { + display: inline-table; + border: none; + width: 100px; + &:focus, + &:hover { + border: none; + box-shadow: none; + } + + .Select-arrow-zone { + padding-left: 10px; + } + } + .Select-menu-outer { + margin-top: 0; + border-bottom-left-radius: 0; + border-bottom-left-radius: 0; + } +`; diff --git a/superset-frontend/src/components/VictoryTheme.js b/superset-frontend/src/components/VictoryTheme.js index 54031a96e535..b673119eb6ec 100644 --- a/superset-frontend/src/components/VictoryTheme.js +++ b/superset-frontend/src/components/VictoryTheme.js @@ -65,116 +65,93 @@ const strokeLinejoin = 'round'; // Create the theme const theme = { - area: assign( - { - style: { - data: { - fill: charcoal, - }, - labels: baseLabelStyles, + area: { + style: { + data: { + fill: charcoal, }, + labels: baseLabelStyles, }, - baseProps, - ), - axis: assign( - { - style: { - axis: { - fill: 'none', - stroke: AXIS_LINE_GRAY, - strokeWidth: 1, - strokeLinecap, - strokeLinejoin, - }, - axisLabel: assign({}, baseLabelStyles, { - padding: 25, - }), - grid: { - fill: 'none', - stroke: 'transparent', - }, - ticks: { - fill: 'none', - padding: 10, - size: 1, - stroke: 'transparent', - }, - tickLabels: baseLabelStyles, + ...baseProps, + }, + axis: { + style: { + axis: { + fill: 'none', + stroke: AXIS_LINE_GRAY, + strokeWidth: 1, + strokeLinecap, + strokeLinejoin, }, - }, - baseProps, - ), - bar: assign( - { - style: { - data: { - fill: A11Y_BABU, - padding: 10, - stroke: 'transparent', - strokeWidth: 0, - width: 8, - }, - labels: baseLabelStyles, + axisLabel: { ...baseLabelStyles, padding: 25 }, + grid: { + fill: 'none', + stroke: 'transparent', + }, + ticks: { + fill: 'none', + padding: 10, + size: 1, + stroke: 'transparent', }, + tickLabels: baseLabelStyles, }, - baseProps, - ), - candlestick: assign( - { - style: { - data: { - stroke: A11Y_BABU, - strokeWidth: 1, - }, - labels: assign({}, baseLabelStyles, { - padding: 25, - textAnchor: 'end', - }), + ...baseProps, + }, + bar: { + style: { + data: { + fill: A11Y_BABU, + padding: 10, + stroke: 'transparent', + strokeWidth: 0, + width: 8, }, - candleColors: { - positive: '#ffffff', - negative: charcoal, + labels: baseLabelStyles, + }, + ...baseProps, + }, + candlestick: { + style: { + data: { + stroke: A11Y_BABU, + strokeWidth: 1, }, + labels: { ...baseLabelStyles, padding: 25, textAnchor: 'end' }, }, - baseProps, - ), + candleColors: { + positive: '#ffffff', + negative: charcoal, + }, + ...baseProps, + }, chart: baseProps, - errorbar: assign( - { - style: { - data: { - fill: 'none', - stroke: charcoal, - strokeWidth: 2, - }, - labels: assign({}, baseLabelStyles, { - textAnchor: 'start', - }), + errorbar: { + style: { + data: { + fill: 'none', + stroke: charcoal, + strokeWidth: 2, }, + labels: { ...baseLabelStyles, textAnchor: 'start' }, }, - baseProps, - ), - group: assign( - { - colorScale: colors, - }, - baseProps, - ), - line: assign( - { - style: { - data: { - fill: 'none', - stroke: A11Y_BABU, - strokeWidth: 2, - }, - labels: assign({}, baseLabelStyles, { - textAnchor: 'start', - }), + ...baseProps, + }, + group: { + colorScale: colors, + ...baseProps, + }, + line: { + style: { + data: { + fill: 'none', + stroke: A11Y_BABU, + strokeWidth: 2, }, + labels: { ...baseLabelStyles, textAnchor: 'start' }, }, - baseProps, - ), + ...baseProps, + }, pie: { style: { data: { @@ -182,37 +159,28 @@ const theme = { stroke: 'none', strokeWidth: 1, }, - labels: assign({}, baseLabelStyles, { - padding: 200, - textAnchor: 'middle', - }), + labels: { ...baseLabelStyles, padding: 200, textAnchor: 'middle' }, }, colorScale: colors, width: 400, height: 400, padding: 50, }, - scatter: assign( - { - style: { - data: { - fill: charcoal, - stroke: 'transparent', - strokeWidth: 0, - }, - labels: assign({}, baseLabelStyles, { - textAnchor: 'middle', - }), + scatter: { + style: { + data: { + fill: charcoal, + stroke: 'transparent', + strokeWidth: 0, }, + labels: { ...baseLabelStyles, textAnchor: 'middle' }, }, - baseProps, - ), - stack: assign( - { - colorScale: colors, - }, - baseProps, - ), + ...baseProps, + }, + stack: { + colorScale: colors, + ...baseProps, + }, }; export default theme; diff --git a/superset-frontend/src/dashboard/App.jsx b/superset-frontend/src/dashboard/App.jsx index c30272c12f5e..baadcfcb9600 100644 --- a/superset-frontend/src/dashboard/App.jsx +++ b/superset-frontend/src/dashboard/App.jsx @@ -16,36 +16,18 @@ * specific language governing permissions and limitations * under the License. */ +import { hot } from 'react-hot-loader/root'; import React from 'react'; -import thunk from 'redux-thunk'; -import { createStore, applyMiddleware, compose } from 'redux'; import { Provider } from 'react-redux'; -import { hot } from 'react-hot-loader/root'; -import { initFeatureFlags } from 'src/featureFlags'; -import { initEnhancer } from '../reduxUtils'; -import logger from '../middleware/loggerMiddleware'; import setupApp from '../setup/setupApp'; import setupPlugins from '../setup/setupPlugins'; import DashboardContainer from './containers/Dashboard'; -import getInitialState from './reducers/getInitialState'; -import rootReducer from './reducers/index'; setupApp(); setupPlugins(); -const appContainer = document.getElementById('app'); -const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap')); -initFeatureFlags(bootstrapData.common.feature_flags); -const initState = getInitialState(bootstrapData); - -const store = createStore( - rootReducer, - initState, - compose(applyMiddleware(thunk, logger), initEnhancer(false)), -); - -const App = () => ( +const App = ({ store }) => ( diff --git a/superset-frontend/src/dashboard/components/FilterIndicatorsContainer.jsx b/superset-frontend/src/dashboard/components/FilterIndicatorsContainer.jsx index 549d3838f044..632942e469ca 100644 --- a/superset-frontend/src/dashboard/components/FilterIndicatorsContainer.jsx +++ b/superset-frontend/src/dashboard/components/FilterIndicatorsContainer.jsx @@ -18,7 +18,7 @@ */ import React from 'react'; import PropTypes from 'prop-types'; -import { isEmpty } from 'lodash'; +import { isEmpty, isNil } from 'lodash'; import FilterIndicator from './FilterIndicator'; import FilterIndicatorGroup from './FilterIndicatorGroup'; @@ -101,6 +101,15 @@ export default class FilterIndicatorsContainer extends React.PureComponent { chartId, column: name, }); + + // filter values could be single value or array of values + const values = + isNil(columns[name]) || + (isDateFilter && columns[name] === 'No filter') || + (Array.isArray(columns[name]) && columns[name].length === 0) + ? [] + : [].concat(columns[name]); + const indicator = { chartId, colorCode: dashboardFiltersColorMap[colorMapKey], @@ -110,11 +119,7 @@ export default class FilterIndicatorsContainer extends React.PureComponent { isInstantFilter, name, label: labels[name] || name, - values: - isEmpty(columns[name]) || - (isDateFilter && columns[name] === 'No filter') - ? [] - : [].concat(columns[name]), + values, isFilterFieldActive: chartId === filterFieldOnFocus.chartId && name === filterFieldOnFocus.column, diff --git a/superset-frontend/src/dashboard/components/Header.jsx b/superset-frontend/src/dashboard/components/Header.jsx index 77aba1d91cab..8ed90a6af4e5 100644 --- a/superset-frontend/src/dashboard/components/Header.jsx +++ b/superset-frontend/src/dashboard/components/Header.jsx @@ -449,7 +449,7 @@ class Header extends React.PureComponent { {this.state.showingPropertiesModal && ( { diff --git a/superset-frontend/src/dashboard/components/PropertiesModal.jsx b/superset-frontend/src/dashboard/components/PropertiesModal.jsx index f266465b8c57..9269299a9c21 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal.jsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal.jsx @@ -20,8 +20,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Row, Col, Button, Modal, FormControl } from 'react-bootstrap'; import Dialog from 'react-bootstrap-dialog'; -import Select from 'react-select'; +import { Async as SelectAsync } from 'react-select'; import AceEditor from 'react-ace'; +import rison from 'rison'; import { t } from '@superset-ui/translation'; import { SupersetClient } from '@superset-ui/connection'; import '../stylesheets/buttons.less'; @@ -55,7 +56,6 @@ class PropertiesModal extends React.PureComponent { json_metadata: '', }, isDashboardLoaded: false, - ownerOptions: null, isAdvancedOpen: false, }; this.onChange = this.onChange.bind(this); @@ -63,10 +63,11 @@ class PropertiesModal extends React.PureComponent { this.onOwnersChange = this.onOwnersChange.bind(this); this.save = this.save.bind(this); this.toggleAdvanced = this.toggleAdvanced.bind(this); + this.loadOwnerOptions = this.loadOwnerOptions.bind(this); + this.handleErrorResponse = this.handleErrorResponse.bind(this); } componentDidMount() { - this.fetchOwnerOptions(); this.fetchDashboardDetails(); } @@ -90,41 +91,42 @@ class PropertiesModal extends React.PureComponent { // 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; - this.setState(state => ({ - isDashboardLoaded: true, - values: { - ...state.values, - dashboard_title: dashboard.dashboard_title || '', - slug: dashboard.slug || '', - json_metadata: dashboard.json_metadata || '', - }, - })); - const initialSelectedValues = dashboard.owners.map(owner => ({ - value: owner.id, - label: owner.username, - })); - this.onOwnersChange(initialSelectedValues); - }) - .catch(err => console.error(err)); + }).then(response => { + const dashboard = response.json.result; + this.setState(state => ({ + isDashboardLoaded: true, + values: { + ...state.values, + dashboard_title: dashboard.dashboard_title || '', + slug: dashboard.slug || '', + json_metadata: dashboard.json_metadata || '', + }, + })); + const initialSelectedOwners = dashboard.owners.map(owner => ({ + value: owner.id, + label: `${owner.first_name} ${owner.last_name}`, + })); + this.onOwnersChange(initialSelectedOwners); + }, this.handleErrorResponse); } - fetchOwnerOptions() { - SupersetClient.get({ - endpoint: `/api/v1/dashboard/related/owners`, - }) - .then(response => { + loadOwnerOptions(input = '') { + const query = rison.encode({ filter: input }); + return SupersetClient.get({ + endpoint: `/api/v1/dashboard/related/owners?q=${query}`, + }).then( + response => { const options = response.json.result.map(item => ({ value: item.value, label: item.text, })); - this.setState({ - ownerOptions: options, - }); - }) - .catch(err => console.error(err)); + return { options }; + }, + badResponse => { + this.handleErrorResponse(badResponse); + return { options: [] }; + }, + ); } updateFormState(name, value) { @@ -142,6 +144,17 @@ class PropertiesModal extends React.PureComponent { })); } + async handleErrorResponse(response) { + const { error, statusText } = await getClientErrorObject(response); + this.dialog.show({ + title: 'Error', + bsSize: 'medium', + bsStyle: 'danger', + actions: [Dialog.DefaultAction('Ok', () => {}, 'btn-danger')], + body: error || statusText || t('An error has occurred'), + }); + } + save(e) { e.preventDefault(); e.stopPropagation(); @@ -157,38 +170,21 @@ class PropertiesModal extends React.PureComponent { json_metadata: values.json_metadata || null, owners, }), - }) - .then(({ json }) => { - this.props.addSuccessToast(t('The dashboard has been saved')); - this.props.onDashboardSave({ - id: this.props.dashboardId, - title: json.result.dashboard_title, - slug: json.result.slug, - jsonMetadata: json.result.json_metadata, - ownerIds: json.result.owners, - }); - this.props.onHide(); - }) - .catch(response => - getClientErrorObject(response).then(({ error, statusText }) => { - this.dialog.show({ - title: 'Error', - bsSize: 'medium', - bsStyle: 'danger', - actions: [Dialog.DefaultAction('Ok', () => {}, 'btn-danger')], - body: error || statusText || t('An error has occurred'), - }); - }), - ); + }).then(({ json }) => { + this.props.addSuccessToast(t('The dashboard has been saved')); + this.props.onDashboardSave({ + id: this.props.dashboardId, + title: json.result.dashboard_title, + slug: json.result.slug, + jsonMetadata: json.result.json_metadata, + ownerIds: json.result.owners, + }); + this.props.onHide(); + }, this.handleErrorResponse); } render() { - const { - ownerOptions, - values, - isDashboardLoaded, - isAdvancedOpen, - } = this.state; + const { values, isDashboardLoaded, isAdvancedOpen } = this.state; return (
@@ -242,17 +238,19 @@ class PropertiesModal extends React.PureComponent { - true} // options are filtered at the api />

- {t('A list of users who can alter the chart')} + {t( + 'A list of users who can alter the chart. Searchable by name or username.', + )}

diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayer.jsx b/superset-frontend/src/explore/components/controls/AnnotationLayer.jsx index b6f2253280ed..4c68cec6c30c 100644 --- a/superset-frontend/src/explore/components/controls/AnnotationLayer.jsx +++ b/superset-frontend/src/explore/components/controls/AnnotationLayer.jsx @@ -25,6 +25,7 @@ import { t } from '@superset-ui/translation'; import { SupersetClient } from '@superset-ui/connection'; import { getCategoricalSchemeRegistry } from '@superset-ui/color'; import { getChartMetadataRegistry } from '@superset-ui/chart'; +import { validateNonEmpty } from '@superset-ui/validator'; import SelectControl from './SelectControl'; import TextControl from './TextControl'; @@ -40,7 +41,6 @@ import ANNOTATION_TYPES, { import PopoverSection from '../../../components/PopoverSection'; import ControlHeader from '../ControlHeader'; -import { nonEmpty } from '../../validators'; import './AnnotationLayer.less'; const AUTOMATIC_COLOR = ''; @@ -215,14 +215,18 @@ export default class AnnotationLayer extends React.PureComponent { timeColumn, intervalEndColumn, } = this.state; - const errors = [nonEmpty(name), nonEmpty(annotationType), nonEmpty(value)]; + const errors = [ + validateNonEmpty(name), + validateNonEmpty(annotationType), + validateNonEmpty(value), + ]; if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE) { if (annotationType === ANNOTATION_TYPES.EVENT) { - errors.push(nonEmpty(timeColumn)); + errors.push(validateNonEmpty(timeColumn)); } if (annotationType === ANNOTATION_TYPES.INTERVAL) { - errors.push(nonEmpty(timeColumn)); - errors.push(nonEmpty(intervalEndColumn)); + errors.push(validateNonEmpty(timeColumn)); + errors.push(validateNonEmpty(intervalEndColumn)); } } errors.push(this.isValidFormula(value, annotationType)); diff --git a/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx b/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx index 58a1b1cf6843..9356a06b6b18 100644 --- a/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx +++ b/superset-frontend/src/explore/components/controls/ColorPickerControl.jsx @@ -88,9 +88,10 @@ export default class ColorPickerControl extends React.Component { } render() { const c = this.props.value || { r: 0, g: 0, b: 0, a: 0 }; - const colStyle = Object.assign({}, styles.color, { + const colStyle = { + ...styles.color, background: `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a})`, - }); + }; return (
diff --git a/superset-frontend/src/explore/components/controls/FixedOrMetricControl.jsx b/superset-frontend/src/explore/components/controls/FixedOrMetricControl.jsx index 5ffcb39eff74..3aca4416c0ef 100644 --- a/superset-frontend/src/explore/components/controls/FixedOrMetricControl.jsx +++ b/superset-frontend/src/explore/components/controls/FixedOrMetricControl.jsx @@ -120,44 +120,49 @@ export default class FixedOrMetricControl extends React.Component { className="panel-spreaded" collapsible expanded={this.state.expanded} + onToggle={this.toggle} > -
- { - this.setType(controlTypes.fixed); - }} - > - { - this.setType(controlTypes.fixed); - }} - value={this.state.fixedValue} - /> - - { - this.setType(controlTypes.metric); - }} - > - { - this.setType(controlTypes.metric); - }} - onChange={this.setMetric} - value={this.state.metricValue} - /> - -
+ + +
+ { + this.setType(controlTypes.fixed); + }} + > + { + this.setType(controlTypes.fixed); + }} + value={this.state.fixedValue} + /> + + { + this.setType(controlTypes.metric); + }} + > + { + this.setType(controlTypes.metric); + }} + onChange={this.setMetric} + value={this.state.metricValue} + /> + +
+
+
); diff --git a/superset-frontend/src/explore/components/controls/HiddenControl.jsx b/superset-frontend/src/explore/components/controls/HiddenControl.jsx index 23fe7410dffb..a6287a056ab6 100644 --- a/superset-frontend/src/explore/components/controls/HiddenControl.jsx +++ b/superset-frontend/src/explore/components/controls/HiddenControl.jsx @@ -26,6 +26,9 @@ const propTypes = { PropTypes.string, PropTypes.number, PropTypes.object, + PropTypes.bool, + PropTypes.array, + PropTypes.func, ]), }; diff --git a/superset-frontend/src/explore/components/controls/TextControl.jsx b/superset-frontend/src/explore/components/controls/TextControl.jsx index e840f181e465..e829aab35f37 100644 --- a/superset-frontend/src/explore/components/controls/TextControl.jsx +++ b/superset-frontend/src/explore/components/controls/TextControl.jsx @@ -19,7 +19,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormGroup, FormControl } from 'react-bootstrap'; -import * as v from '../../validators'; +import { + legacyValidateNumber, + legacyValidateInteger, +} from '@superset-ui/validator'; import ControlHeader from '../ControlHeader'; const propTypes = { @@ -51,15 +54,15 @@ export default class TextControl extends React.Component { // Validation & casting const errors = []; if (value !== '' && this.props.isFloat) { - const error = v.numeric(value); + const error = legacyValidateNumber(value); if (error) { errors.push(error); } else { - value = parseFloat(value); + value = value.match(/.*(\.)$/g) ? value : parseFloat(value); } } if (value !== '' && this.props.isInt) { - const error = v.integer(value); + const error = legacyValidateInteger(value); if (error) { errors.push(error); } else { diff --git a/superset-frontend/src/explore/controlPanels/Area.js b/superset-frontend/src/explore/controlPanels/Area.js index 1c01fa482523..e471b1693ebd 100644 --- a/superset-frontend/src/explore/controlPanels/Area.js +++ b/superset-frontend/src/explore/controlPanels/Area.js @@ -19,6 +19,20 @@ import { t } from '@superset-ui/translation'; import { NVD3TimeSeries, annotations } from './sections'; import { D3_TIME_FORMAT_OPTIONS } from '../controls'; +import { + lineInterpolation, + showBrush, + showLegend, + showControls, + xAxisLabel, + bottomMargin, + xTicksLayout, + xAxisFormat, + yLogScale, + yAxisBounds, + xAxisShowMinmax, + richTooltip, +} from './Shared_NVD3'; export default { requiresTime: true, @@ -28,37 +42,50 @@ export default { label: t('Chart Options'), expanded: true, controlSetRows: [ - ['show_brush', 'show_legend'], - ['line_interpolation', 'stacked_style'], + [showBrush, showLegend], + [ + lineInterpolation, + { + name: 'stacked_style', + config: { + type: 'SelectControl', + label: t('Stacked Style'), + renderTrigger: true, + choices: [ + ['stack', 'stack'], + ['stream', 'stream'], + ['expand', 'expand'], + ], + default: 'stack', + description: '', + }, + }, + ], ['color_scheme', 'label_colors'], - ['rich_tooltip', 'show_controls'], + [richTooltip, showControls], ], }, { label: t('X Axis'), expanded: true, controlSetRows: [ - ['x_axis_label', 'bottom_margin'], - ['x_ticks_layout', 'x_axis_format'], - ['x_axis_showminmax', null], + [xAxisLabel, bottomMargin], + [xTicksLayout, xAxisFormat], + [xAxisShowMinmax, null], ], }, { label: t('Y Axis'), expanded: true, controlSetRows: [ - ['y_axis_format', 'y_axis_bounds'], - ['y_log_scale', null], + ['y_axis_format', yAxisBounds], + [yLogScale, null], ], }, NVD3TimeSeries[1], annotations, ], controlOverrides: { - x_axis_format: { - default: 'smart_date', - choices: D3_TIME_FORMAT_OPTIONS, - }, color_scheme: { renderTrigger: false, }, diff --git a/superset-frontend/src/explore/controlPanels/Bar.js b/superset-frontend/src/explore/controlPanels/Bar.js index c7ba0c9ed644..6df2569ae785 100644 --- a/superset-frontend/src/explore/controlPanels/Bar.js +++ b/superset-frontend/src/explore/controlPanels/Bar.js @@ -19,6 +19,26 @@ import { t } from '@superset-ui/translation'; import { NVD3TimeSeries, annotations } from './sections'; import { D3_TIME_FORMAT_OPTIONS } from '../controls'; +import { + lineInterpolation, + showBrush, + showLegend, + showControls, + xAxisLabel, + yAxisLabel, + bottomMargin, + xTicksLayout, + xAxisFormat, + yLogScale, + yAxisBounds, + xAxisShowMinmax, + yAxisShowMinmax, + richTooltip, + showBarValue, + barStacked, + reduceXTicks, + leftMargin, +} from './Shared_NVD3'; export default { requiresTime: true, @@ -29,39 +49,33 @@ export default { expanded: true, controlSetRows: [ ['color_scheme', 'label_colors'], - ['show_brush', 'show_legend', 'show_bar_value'], - ['rich_tooltip', 'bar_stacked'], - ['line_interpolation', 'show_controls'], - ['bottom_margin'], + [showBrush, showLegend, showBarValue], + [richTooltip, barStacked], + [lineInterpolation, showControls], + [bottomMargin], ], }, { label: t('X Axis'), expanded: true, controlSetRows: [ - ['x_axis_label', 'bottom_margin'], - ['x_ticks_layout', 'x_axis_format'], - ['x_axis_showminmax', 'reduce_x_ticks'], + [xAxisLabel, bottomMargin], + [xTicksLayout, xAxisFormat], + [xAxisShowMinmax, reduceXTicks], ], }, { label: t('Y Axis'), expanded: true, controlSetRows: [ - ['y_axis_label', 'left_margin'], - ['y_axis_showminmax', 'y_log_scale'], - ['y_axis_format', 'y_axis_bounds'], + [yAxisLabel, leftMargin], + [yAxisShowMinmax, yLogScale], + ['y_axis_format', yAxisBounds], ], }, NVD3TimeSeries[1], annotations, ], - controlOverrides: { - x_axis_format: { - choices: D3_TIME_FORMAT_OPTIONS, - default: 'smart_date', - }, - }, sectionOverrides: { druidTimeSeries: { controlSetRows: [['granularity', 'druid_time_origin'], ['time_range']], diff --git a/superset-frontend/src/explore/controlPanels/BigNumber.js b/superset-frontend/src/explore/controlPanels/BigNumber.jsx similarity index 54% rename from superset-frontend/src/explore/controlPanels/BigNumber.js rename to superset-frontend/src/explore/controlPanels/BigNumber.jsx index 4a708f92b807..f3030968552f 100644 --- a/superset-frontend/src/explore/controlPanels/BigNumber.js +++ b/superset-frontend/src/explore/controlPanels/BigNumber.jsx @@ -18,6 +18,7 @@ */ import { t } from '@superset-ui/translation'; import React from 'react'; +import { headerFontSize, subheaderFontSize } from './Shared_BigNumber'; export default { controlPanelSections: [ @@ -28,11 +29,56 @@ export default { }, { label: t('Options'), + tabOverride: 'data', expanded: true, controlSetRows: [ - ['compare_lag', 'compare_suffix'], - ['y_axis_format', null], - ['show_trend_line', 'start_y_axis_at_zero'], + [ + { + name: 'compare_lag', + config: { + type: 'TextControl', + label: t('Comparison Period Lag'), + isInt: true, + description: t( + 'Based on granularity, number of time periods to compare against', + ), + }, + }, + { + name: 'compare_suffix', + config: { + type: 'TextControl', + label: t('Comparison suffix'), + description: t('Suffix to apply after the percentage display'), + }, + }, + ], + ['y_axis_format'], + [ + { + name: 'show_trend_line', + config: { + type: 'CheckboxControl', + label: t('Show Trend Line'), + renderTrigger: true, + default: true, + description: t('Whether to display the trend line'), + }, + }, + { + name: 'start_y_axis_at_zero', + config: { + type: 'CheckboxControl', + label: t('Start y-axis at 0'), + renderTrigger: true, + default: true, + description: t( + 'Start y-axis at zero. Uncheck to start y-axis at minimum value in the data.', + ), + }, + }, + ], + ['time_range_fixed'], ], }, { @@ -40,8 +86,8 @@ export default { expanded: true, controlSetRows: [ ['color_picker', null], - ['header_font_size'], - ['subheader_font_size'], + [headerFontSize], + [subheaderFontSize], ], }, { @@ -57,9 +103,6 @@ export default { y_axis_format: { label: t('Number format'), }, - header_font_size: { - label: t('Big Number Font Size'), - }, }, sectionOverrides: { druidTimeSeries: { diff --git a/superset-frontend/src/explore/controlPanels/BigNumberTotal.js b/superset-frontend/src/explore/controlPanels/BigNumberTotal.js index 1814404ea638..720f28823b5a 100644 --- a/superset-frontend/src/explore/controlPanels/BigNumberTotal.js +++ b/superset-frontend/src/explore/controlPanels/BigNumberTotal.js @@ -17,6 +17,7 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { headerFontSize, subheaderFontSize } from './Shared_BigNumber'; export default { controlPanelSections: [ @@ -28,20 +29,31 @@ export default { { label: t('Options'), expanded: true, - controlSetRows: [['subheader'], ['y_axis_format']], + controlSetRows: [ + [ + { + name: 'subheader', + config: { + type: 'TextControl', + label: t('Subheader'), + description: t( + 'Description text that shows up below your Big Number', + ), + }, + }, + ], + ['y_axis_format'], + ], }, { label: t('Chart Options'), expanded: true, - controlSetRows: [['header_font_size'], ['subheader_font_size']], + controlSetRows: [[headerFontSize], [subheaderFontSize]], }, ], controlOverrides: { y_axis_format: { label: t('Number format'), }, - header_font_size: { - label: t('Big Number Font Size'), - }, }, }; diff --git a/superset-frontend/src/explore/controlPanels/BoxPlot.js b/superset-frontend/src/explore/controlPanels/BoxPlot.js index 0f4cb16b2923..ecf58e2822aa 100644 --- a/superset-frontend/src/explore/controlPanels/BoxPlot.js +++ b/superset-frontend/src/explore/controlPanels/BoxPlot.js @@ -17,6 +17,7 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { formatSelectOptions } from '../../modules/utils'; export default { controlPanelSections: [ @@ -30,7 +31,43 @@ export default { expanded: true, controlSetRows: [ ['color_scheme', 'label_colors'], - ['whisker_options', 'x_ticks_layout'], + [ + { + name: 'whisker_options', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Whisker/outlier options'), + default: 'Tukey', + description: t( + 'Determines how whiskers and outliers are calculated.', + ), + choices: formatSelectOptions([ + 'Tukey', + 'Min/max (no outliers)', + '2/98 percentiles', + '9/91 percentiles', + ]), + }, + }, + { + name: 'x_ticks_layout', + config: { + type: 'SelectControl', + label: t('X Tick Layout'), + choices: formatSelectOptions([ + 'auto', + 'flat', + '45°', + 'staggered', + ]), + default: 'auto', + clearable: false, + renderTrigger: true, + description: t('The way the ticks are laid out on the X-axis'), + }, + }, + ], ], }, ], diff --git a/superset-frontend/src/explore/controlPanels/Bubble.js b/superset-frontend/src/explore/controlPanels/Bubble.js index 891fe29cf152..169cb202fb1a 100644 --- a/superset-frontend/src/explore/controlPanels/Bubble.js +++ b/superset-frontend/src/explore/controlPanels/Bubble.js @@ -17,6 +17,20 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { D3_FORMAT_OPTIONS } from '../controls'; +import { formatSelectOptions } from '../../modules/utils'; +import { + showLegend, + xAxisLabel, + yAxisLabel, + bottomMargin, + xTicksLayout, + xAxisFormat, + yLogScale, + xAxisShowMinmax, + yAxisShowMinmax, + leftMargin, +} from './Shared_NVD3'; export default { label: t('Bubble Chart'), @@ -30,7 +44,26 @@ export default { ['y'], ['adhoc_filters'], ['size'], - ['max_bubble_size'], + [ + { + name: 'max_bubble_size', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Max Bubble Size'), + default: '25', + choices: formatSelectOptions([ + '5', + '10', + '15', + '25', + '50', + '75', + '100', + ]), + }, + }, + ], ['limit', null], ], }, @@ -39,25 +72,47 @@ export default { expanded: true, controlSetRows: [ ['color_scheme', 'label_colors'], - ['show_legend', null], + [showLegend, null], ], }, { label: t('X Axis'), expanded: true, controlSetRows: [ - ['x_axis_label', 'left_margin'], - ['x_axis_format', 'x_ticks_layout'], - ['x_log_scale', 'x_axis_showminmax'], + [xAxisLabel, leftMargin], + [ + { + name: xAxisFormat.name, + config: { + ...xAxisFormat.config, + default: 'SMART_NUMBER', + choices: D3_FORMAT_OPTIONS, + }, + }, + xTicksLayout, + ], + [ + { + name: 'x_log_scale', + config: { + type: 'CheckboxControl', + label: t('X Log Scale'), + default: false, + renderTrigger: true, + description: t('Use a log scale for the X-axis'), + }, + }, + xAxisShowMinmax, + ], ], }, { label: t('Y Axis'), expanded: true, controlSetRows: [ - ['y_axis_label', 'bottom_margin'], + [yAxisLabel, bottomMargin], ['y_axis_format', null], - ['y_log_scale', 'y_axis_showminmax'], + [yLogScale, yAxisShowMinmax], ], }, ], diff --git a/superset-frontend/src/explore/controlPanels/Bullet.js b/superset-frontend/src/explore/controlPanels/Bullet.js index b0e4252e130b..33281d13b669 100644 --- a/superset-frontend/src/explore/controlPanels/Bullet.js +++ b/superset-frontend/src/explore/controlPanels/Bullet.js @@ -30,9 +30,66 @@ export default { label: t('Chart Options'), expanded: true, controlSetRows: [ - ['ranges', 'range_labels'], - ['markers', 'marker_labels'], - ['marker_lines', 'marker_line_labels'], + [ + { + name: 'ranges', + config: { + type: 'TextControl', + label: t('Ranges'), + default: '', + description: t('Ranges to highlight with shading'), + }, + }, + { + name: 'range_labels', + config: { + type: 'TextControl', + label: t('Range labels'), + default: '', + description: t('Labels for the ranges'), + }, + }, + ], + [ + { + name: 'markers', + config: { + type: 'TextControl', + label: t('Markers'), + default: '', + description: t('List of values to mark with triangles'), + }, + }, + { + name: 'marker_labels', + config: { + type: 'TextControl', + label: t('Marker labels'), + default: '', + description: t('Labels for the markers'), + }, + }, + ], + [ + { + name: 'marker_lines', + config: { + type: 'TextControl', + label: t('Marker lines'), + default: '', + description: t('List of values to mark with lines'), + }, + }, + { + name: 'marker_line_labels', + config: { + type: 'TextControl', + label: t('Marker line labels'), + default: '', + description: t('Labels for the marker lines'), + }, + }, + ], ], }, ], diff --git a/superset-frontend/src/explore/controlPanels/CalHeatmap.js b/superset-frontend/src/explore/controlPanels/CalHeatmap.js index 85e01410fa0d..c91506533c74 100644 --- a/superset-frontend/src/explore/controlPanels/CalHeatmap.js +++ b/superset-frontend/src/explore/controlPanels/CalHeatmap.js @@ -17,6 +17,13 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { legacyValidateInteger } from '@superset-ui/validator'; +import { + // formatSelectOptionsForRange, + formatSelectOptions, + // mainMetric, +} from '../../modules/utils'; +import { D3_TIME_FORMAT_OPTIONS, D3_FORMAT_DOCS } from '../controls'; export default { requiresTime: true, @@ -25,7 +32,43 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['domain_granularity', 'subdomain_granularity'], + [ + { + name: 'domain_granularity', + config: { + type: 'SelectControl', + label: t('Domain'), + default: 'month', + choices: formatSelectOptions([ + 'hour', + 'day', + 'week', + 'month', + 'year', + ]), + description: t('The time unit used for the grouping of blocks'), + }, + }, + { + name: 'subdomain_granularity', + config: { + type: 'SelectControl', + label: t('Subdomain'), + default: 'day', + choices: formatSelectOptions([ + 'min', + 'hour', + 'day', + 'week', + 'month', + ]), + description: t( + 'The time unit for each block. Should be a smaller unit than ' + + 'domain_granularity. Should be larger or equal to Time Grain', + ), + }, + }, + ], ['metrics'], ['adhoc_filters'], ], @@ -35,11 +78,99 @@ export default { expanded: true, controlSetRows: [ ['linear_color_scheme'], - ['cell_size', 'cell_padding'], - ['cell_radius', 'steps'], - ['y_axis_format', 'x_axis_time_format'], - ['show_legend', 'show_values'], - ['show_metric_name', null], + [ + { + name: 'cell_size', + config: { + type: 'TextControl', + isInt: true, + default: 10, + validators: [legacyValidateInteger], + renderTrigger: true, + label: t('Cell Size'), + description: t('The size of the square cell, in pixels'), + }, + }, + { + name: 'cell_padding', + config: { + type: 'TextControl', + isInt: true, + validators: [legacyValidateInteger], + renderTrigger: true, + default: 2, + label: t('Cell Padding'), + description: t('The distance between cells, in pixels'), + }, + }, + ], + [ + { + name: 'cell_radius', + config: { + type: 'TextControl', + isInt: true, + validators: [legacyValidateInteger], + renderTrigger: true, + default: 0, + label: t('Cell Radius'), + description: t('The pixel radius'), + }, + }, + { + name: 'steps', + config: { + type: 'TextControl', + isInt: true, + validators: [legacyValidateInteger], + renderTrigger: true, + default: 10, + label: t('Color Steps'), + description: t('The number color "steps"'), + }, + }, + ], + [ + 'y_axis_format', + { + name: 'x_axis_time_format', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Time Format'), + renderTrigger: true, + default: 'smart_date', + choices: D3_TIME_FORMAT_OPTIONS, + description: D3_FORMAT_DOCS, + }, + }, + ], + [ + { + name: 'show_legend', + config: { + type: 'CheckboxControl', + label: t('Legend'), + renderTrigger: true, + default: true, + description: t('Whether to display the legend (toggles)'), + }, + }, + 'show_values', + ], + [ + { + name: 'show_metric_name', + config: { + type: 'CheckboxControl', + label: t('Show Metric Names'), + renderTrigger: true, + default: true, + description: t('Whether to display the metric name as a title'), + }, + }, + null, + ], ], }, ], @@ -47,9 +178,6 @@ export default { y_axis_format: { label: t('Number Format'), }, - x_axis_time_format: { - label: t('Time Format'), - }, show_values: { default: false, }, diff --git a/superset-frontend/src/explore/controlPanels/Chord.js b/superset-frontend/src/explore/controlPanels/Chord.js index b932c3f0e503..f32a03b438d7 100644 --- a/superset-frontend/src/explore/controlPanels/Chord.js +++ b/superset-frontend/src/explore/controlPanels/Chord.js @@ -17,7 +17,7 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { nonEmpty } from '../validators'; +import { validateNonEmpty } from '@superset-ui/validator'; export default { controlPanelSections: [ @@ -49,13 +49,13 @@ export default { groupby: { label: t('Source'), multi: false, - validators: [nonEmpty], + validators: [validateNonEmpty], description: t('Choose a source'), }, columns: { label: t('Target'), multi: false, - validators: [nonEmpty], + validators: [validateNonEmpty], description: t('Choose a target'), }, }, diff --git a/superset-frontend/src/explore/controlPanels/Compare.js b/superset-frontend/src/explore/controlPanels/Compare.js index 99ed3004f914..82f430813ffb 100644 --- a/superset-frontend/src/explore/controlPanels/Compare.js +++ b/superset-frontend/src/explore/controlPanels/Compare.js @@ -19,6 +19,18 @@ import { t } from '@superset-ui/translation'; import { NVD3TimeSeries, annotations } from './sections'; import { D3_TIME_FORMAT_OPTIONS } from '../controls'; +import { + xAxisLabel, + yAxisLabel, + bottomMargin, + xTicksLayout, + xAxisFormat, + yLogScale, + yAxisBounds, + xAxisShowMinmax, + yAxisShowMinmax, + leftMargin, +} from './Shared_NVD3'; export default { requiresTime: true, @@ -33,29 +45,23 @@ export default { label: t('X Axis'), expanded: true, controlSetRows: [ - ['x_axis_label', 'bottom_margin'], - ['x_ticks_layout', 'x_axis_format'], - ['x_axis_showminmax', null], + [xAxisLabel, bottomMargin], + [xTicksLayout, xAxisFormat], + [xAxisShowMinmax, null], ], }, { label: t('Y Axis'), expanded: true, controlSetRows: [ - ['y_axis_label', 'left_margin'], - ['y_axis_showminmax', 'y_log_scale'], - ['y_axis_format', 'y_axis_bounds'], + [yAxisLabel, leftMargin], + [yAxisShowMinmax, yLogScale], + ['y_axis_format', yAxisBounds], ], }, NVD3TimeSeries[1], annotations, ], - controlOverrides: { - x_axis_format: { - choices: D3_TIME_FORMAT_OPTIONS, - default: 'smart_date', - }, - }, sectionOverrides: { druidTimeSeries: { controlSetRows: [['granularity', 'druid_time_origin'], ['time_range']], diff --git a/superset-frontend/src/explore/controlPanels/DeckArc.js b/superset-frontend/src/explore/controlPanels/DeckArc.js index 1a8c3b9f9cb0..80c111d52d2d 100644 --- a/superset-frontend/src/explore/controlPanels/DeckArc.js +++ b/superset-frontend/src/explore/controlPanels/DeckArc.js @@ -17,7 +17,25 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { + validateNonEmpty, + legacyValidateInteger, +} from '@superset-ui/validator'; import timeGrainSqlaAnimationOverrides from './timeGrainSqlaAnimationOverrides'; +import { columnChoices, PRIMARY_COLOR } from '../controls'; +import { formatSelectOptions } from '../../modules/utils'; +import { + filterNulls, + autozoom, + dimension, + jsColumns, + jsDataMutator, + jsTooltip, + jsOnclickHref, + legendFormat, + legendPosition, + viewport, +} from './Shared_DeckGL'; export default { requiresTime: true, @@ -26,44 +44,99 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['start_spatial', 'end_spatial'], - ['row_limit', 'filter_nulls'], + [ + { + name: 'start_spatial', + config: { + type: 'SpatialControl', + label: t('Start Longitude & Latitude'), + validators: [validateNonEmpty], + description: t('Point to your spatial columns'), + mapStateToProps: state => ({ + choices: columnChoices(state.datasource), + }), + }, + }, + { + name: 'end_spatial', + config: { + type: 'SpatialControl', + label: t('End Longitude & Latitude'), + validators: [validateNonEmpty], + description: t('Point to your spatial columns'), + mapStateToProps: state => ({ + choices: columnChoices(state.datasource), + }), + }, + }, + ], + ['row_limit', filterNulls], ['adhoc_filters'], ], }, { label: t('Map'), controlSetRows: [ - ['mapbox_style', 'viewport'], - ['autozoom', null], + ['mapbox_style', viewport], + [autozoom, null], ], }, { label: t('Arc'), controlSetRows: [ - ['color_picker', 'target_color_picker'], - ['dimension', 'color_scheme', 'label_colors'], - ['stroke_width', 'legend_position'], - ['legend_format', null], + [ + 'color_picker', + { + name: 'target_color_picker', + config: { + label: t('Target Color'), + description: t('Color of the target location'), + type: 'ColorPickerControl', + default: PRIMARY_COLOR, + renderTrigger: true, + }, + }, + ], + [ + { + ...dimension, + label: t('Categorical Color'), + description: t( + 'Pick a dimension from which categorical colors are defined', + ), + }, + 'color_scheme', + 'label_colors', + ], + [ + { + name: 'stroke_width', + color: { + type: 'SelectControl', + freeForm: true, + label: t('Stroke Width'), + validators: [legacyValidateInteger], + default: null, + renderTrigger: true, + choices: formatSelectOptions([1, 2, 3, 4, 5]), + }, + }, + legendPosition, + ], + [legendFormat, null], ], }, { label: t('Advanced'), controlSetRows: [ - ['js_columns'], - ['js_data_mutator'], - ['js_tooltip'], - ['js_onclick_href'], + [jsColumns], + [jsDataMutator], + [jsTooltip], + [jsOnclickHref], ], }, ], controlOverrides: { - dimension: { - label: t('Categorical Color'), - description: t( - 'Pick a dimension from which categorical colors are defined', - ), - }, size: { validators: [], }, diff --git a/superset-frontend/src/explore/controlPanels/DeckGeojson.js b/superset-frontend/src/explore/controlPanels/DeckGeojson.js index 63dd8e0a7d29..f59c2d0d84ba 100644 --- a/superset-frontend/src/explore/controlPanels/DeckGeojson.js +++ b/superset-frontend/src/explore/controlPanels/DeckGeojson.js @@ -17,6 +17,25 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { + validateNonEmpty, + legacyValidateInteger, +} from '@superset-ui/validator'; +import { formatSelectOptions } from '../../modules/utils'; +import { columnChoices } from '../controls'; +import { + filterNulls, + jsColumns, + jsDataMutator, + jsTooltip, + jsOnclickHref, + fillColorPicker, + strokeColorPicker, + filled, + stroked, + extruded, + viewport, +} from './Shared_DeckGL'; export default { requiresTime: true, @@ -25,34 +44,61 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['geojson', null], - ['row_limit', 'filter_nulls'], + [ + { + name: 'geojson', + config: { + type: 'SelectControl', + label: t('GeoJson Column'), + validators: [validateNonEmpty], + description: t('Select the geojson column'), + mapStateToProps: state => ({ + choices: columnChoices(state.datasource), + }), + }, + }, + null, + ], + ['row_limit', filterNulls], ['adhoc_filters'], ], }, { label: t('Map'), controlSetRows: [ - ['mapbox_style', 'viewport'], - // TODO ['autozoom', null], + ['mapbox_style', viewport], + // TODO [autozoom, null], // import { autozoom } from './Shared_DeckGL' ], }, { label: t('GeoJson Settings'), controlSetRows: [ - ['fill_color_picker', 'stroke_color_picker'], - ['filled', 'stroked'], - ['extruded', null], - ['point_radius_scale', null], + [fillColorPicker, strokeColorPicker], + [filled, stroked], + [extruded, null], + [ + { + name: 'point_radius_scale', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Point Radius Scale'), + validators: [legacyValidateInteger], + default: null, + choices: formatSelectOptions([0, 100, 200, 300, 500]), + }, + }, + null, + ], ], }, { label: t('Advanced'), controlSetRows: [ - ['js_columns'], - ['js_data_mutator'], - ['js_tooltip'], - ['js_onclick_href'], + [jsColumns], + [jsDataMutator], + [jsTooltip], + [jsOnclickHref], ], }, ], diff --git a/superset-frontend/src/explore/controlPanels/DeckGrid.js b/superset-frontend/src/explore/controlPanels/DeckGrid.js index 51ed6f011442..0482ab14fc4f 100644 --- a/superset-frontend/src/explore/controlPanels/DeckGrid.js +++ b/superset-frontend/src/explore/controlPanels/DeckGrid.js @@ -17,7 +17,19 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { nonEmpty } from '../validators'; +import { validateNonEmpty } from '@superset-ui/validator'; +import { + filterNulls, + autozoom, + jsColumns, + jsDataMutator, + jsTooltip, + jsOnclickHref, + extruded, + gridSize, + viewport, + spatial, +} from './Shared_DeckGL'; export default { requiresTime: true, @@ -26,26 +38,26 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['spatial', 'size'], - ['row_limit', 'filter_nulls'], + [spatial, 'size'], + ['row_limit', filterNulls], ['adhoc_filters'], ], }, { label: t('Map'), controlSetRows: [ - ['mapbox_style', 'viewport'], - ['color_picker', 'autozoom'], - ['grid_size', 'extruded'], + ['mapbox_style', viewport], + ['color_picker', autozoom], + [gridSize, extruded], ], }, { label: t('Advanced'), controlSetRows: [ - ['js_columns'], - ['js_data_mutator'], - ['js_tooltip'], - ['js_onclick_href'], + [jsColumns], + [jsDataMutator], + [jsTooltip], + [jsOnclickHref], ], }, ], @@ -53,7 +65,7 @@ export default { size: { label: t('Height'), description: t('Metric used to control height'), - validators: [nonEmpty], + validators: [validateNonEmpty], }, }, }; diff --git a/superset-frontend/src/explore/controlPanels/DeckHex.js b/superset-frontend/src/explore/controlPanels/DeckHex.js index 62daeb94fe40..6edf4cd7809d 100644 --- a/superset-frontend/src/explore/controlPanels/DeckHex.js +++ b/superset-frontend/src/explore/controlPanels/DeckHex.js @@ -17,6 +17,22 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { + formatSelectOptions, + formatSelectOptionsForRange, +} from '../../modules/utils'; +import { + filterNulls, + autozoom, + jsColumns, + jsDataMutator, + jsTooltip, + jsOnclickHref, + extruded, + gridSize, + viewport, + spatial, +} from './Shared_DeckGL'; export default { requiresTime: true, @@ -25,27 +41,56 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['spatial', 'size'], - ['row_limit', 'filter_nulls'], + [spatial, 'size'], + ['row_limit', filterNulls], ['adhoc_filters'], ], }, { label: t('Map'), controlSetRows: [ - ['mapbox_style', 'viewport'], - ['color_picker', 'autozoom'], - ['grid_size', 'extruded'], - ['js_agg_function', null], + ['mapbox_style', viewport], + ['color_picker', autozoom], + [gridSize, extruded], + [ + { + name: 'js_agg_function', + config: { + type: 'SelectControl', + label: t('Dynamic Aggregation Function'), + description: t( + 'The function to use when aggregating points into groups', + ), + default: 'sum', + clearable: false, + renderTrigger: true, + choices: formatSelectOptions([ + 'sum', + 'min', + 'max', + 'mean', + 'median', + 'count', + 'variance', + 'deviation', + 'p1', + 'p5', + 'p95', + 'p99', + ]), + }, + }, + null, + ], ], }, { label: t('Advanced'), controlSetRows: [ - ['js_columns'], - ['js_data_mutator'], - ['js_tooltip'], - ['js_onclick_href'], + [jsColumns], + [jsDataMutator], + [jsTooltip], + [jsOnclickHref], ], }, ], diff --git a/superset-frontend/src/explore/controlPanels/DeckMulti.js b/superset-frontend/src/explore/controlPanels/DeckMulti.js index d61b6dba3654..b801cdd8a6bb 100644 --- a/superset-frontend/src/explore/controlPanels/DeckMulti.js +++ b/superset-frontend/src/explore/controlPanels/DeckMulti.js @@ -17,6 +17,8 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { validateNonEmpty } from '@superset-ui/validator'; +import { viewport } from './Shared_DeckGL'; export default { requiresTime: true, @@ -25,8 +27,36 @@ export default { label: t('Map'), expanded: true, controlSetRows: [ - ['mapbox_style', 'viewport'], - ['deck_slices', null], + ['mapbox_style', viewport], + [ + { + name: 'deck_slices', + config: { + type: 'SelectAsyncControl', + multi: true, + label: t('deck.gl charts'), + validators: [validateNonEmpty], + default: [], + description: t( + 'Pick a set of deck.gl charts to layer on top of one another', + ), + dataEndpoint: + '/sliceasync/api/read?_flt_0_viz_type=deck_&_flt_7_viz_type=deck_multi', + placeholder: t('Select charts'), + onAsyncErrorMessage: t('Error while fetching charts'), + mutator: data => { + if (!data || !data.result) { + return []; + } + return data.result.map(o => ({ + value: o.id, + label: o.slice_name, + })); + }, + }, + }, + null, + ], ], }, { diff --git a/superset-frontend/src/explore/controlPanels/DeckPath.js b/superset-frontend/src/explore/controlPanels/DeckPath.js index 246d5ec0e61b..ddedcc43f34f 100644 --- a/superset-frontend/src/explore/controlPanels/DeckPath.js +++ b/superset-frontend/src/explore/controlPanels/DeckPath.js @@ -17,6 +17,19 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { + filterNulls, + autozoom, + jsColumns, + jsDataMutator, + jsTooltip, + jsOnclickHref, + lineColumn, + viewport, + lineWidth, + lineType, + reverseLongLat, +} from './Shared_DeckGL'; export default { requiresTime: true, @@ -25,8 +38,17 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['line_column', 'line_type'], - ['row_limit', 'filter_nulls'], + [ + lineColumn, + { + ...lineType, + choices: [ + ['polyline', 'Polyline'], + ['json', 'JSON'], + ], + }, + ], + ['row_limit', filterNulls], ['adhoc_filters'], ], }, @@ -34,27 +56,19 @@ export default { label: t('Map'), expanded: true, controlSetRows: [ - ['mapbox_style', 'viewport'], - ['color_picker', 'line_width'], - ['reverse_long_lat', 'autozoom'], + ['mapbox_style', viewport], + ['color_picker', lineWidth], + [reverseLongLat, autozoom], ], }, { label: t('Advanced'), controlSetRows: [ - ['js_columns'], - ['js_data_mutator'], - ['js_tooltip'], - ['js_onclick_href'], + [jsColumns], + [jsDataMutator], + [jsTooltip], + [jsOnclickHref], ], }, ], - controlOverrides: { - line_type: { - choices: [ - ['polyline', 'Polyline'], - ['json', 'JSON'], - ], - }, - }, }; diff --git a/superset-frontend/src/explore/controlPanels/DeckPolygon.js b/superset-frontend/src/explore/controlPanels/DeckPolygon.js index cc29b84c5caf..99c4f993d9fe 100644 --- a/superset-frontend/src/explore/controlPanels/DeckPolygon.js +++ b/superset-frontend/src/explore/controlPanels/DeckPolygon.js @@ -18,6 +18,29 @@ */ import { t } from '@superset-ui/translation'; import timeGrainSqlaAnimationOverrides from './timeGrainSqlaAnimationOverrides'; +import { formatSelectOptions } from '../../modules/utils'; +import { + filterNulls, + autozoom, + jsColumns, + jsDataMutator, + jsTooltip, + jsOnclickHref, + legendFormat, + legendPosition, + lineColumn, + fillColorPicker, + strokeColorPicker, + filled, + stroked, + extruded, + viewport, + pointRadiusFixed, + multiplier, + lineWidth, + lineType, + reverseLongLat, +} from './Shared_DeckGL'; export default { requiresTime: true, @@ -26,42 +49,102 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['line_column', 'line_type'], + [ + { ...lineColumn, label: t('Polygon Column') }, + { ...lineType, label: t('Polygon Encoding') }, + ], ['adhoc_filters'], - ['metric', 'point_radius_fixed'], + ['metric', { ...pointRadiusFixed, label: t('Elevation') }], ['row_limit', null], - ['reverse_long_lat', 'filter_nulls'], + [reverseLongLat, filterNulls], ], }, { label: t('Map'), expanded: true, controlSetRows: [ - ['mapbox_style', 'viewport'], - ['autozoom', null], + ['mapbox_style', viewport], + [autozoom, null], ], }, { label: t('Polygon Settings'), expanded: true, controlSetRows: [ - ['fill_color_picker', 'stroke_color_picker'], - ['filled', 'stroked'], - ['extruded', 'multiplier'], - ['line_width', null], - ['linear_color_scheme', 'opacity'], - ['num_buckets', 'break_points'], - ['table_filter', 'toggle_polygons'], - ['legend_position', 'legend_format'], + [fillColorPicker, strokeColorPicker], + [filled, stroked], + [extruded, multiplier], + [lineWidth, null], + [ + 'linear_color_scheme', + { + name: 'opacity', + config: { + type: 'SliderControl', + label: t('Opacity'), + default: 80, + step: 1, + min: 0, + max: 100, + renderTrigger: true, + description: t('Opacity, expects values between 0 and 100'), + }, + }, + ], + [ + { + name: 'num_buckets', + config: { + type: 'SelectControl', + multi: false, + freeForm: true, + label: t('Number of buckets to group data'), + default: 5, + choices: formatSelectOptions([2, 3, 5, 10]), + description: t('How many buckets should the data be grouped in.'), + renderTrigger: true, + }, + }, + { + name: 'break_points', + config: { + type: 'SelectControl', + multi: true, + freeForm: true, + label: t('Bucket break points'), + choices: formatSelectOptions([]), + description: t( + 'List of n+1 values for bucketing metric into n buckets.', + ), + renderTrigger: true, + }, + }, + ], + [ + 'table_filter', + { + name: 'toggle_polygons', + config: { + type: 'CheckboxControl', + label: t('Multiple filtering'), + renderTrigger: true, + default: true, + description: t( + 'Allow sending multiple polygons as a filter event', + ), + }, + }, + ], + [legendPosition, legendFormat], ], }, { label: t('Advanced'), controlSetRows: [ - ['js_columns'], - ['js_data_mutator'], - ['js_tooltip'], - ['js_onclick_href'], + [jsColumns], + [jsDataMutator], + [jsTooltip], + [jsOnclickHref], ], }, ], @@ -69,15 +152,6 @@ export default { metric: { validators: [], }, - line_column: { - label: t('Polygon Column'), - }, - line_type: { - label: t('Polygon Encoding'), - }, - point_radius_fixed: { - label: t('Elevation'), - }, time_grain_sqla: timeGrainSqlaAnimationOverrides, }, }; diff --git a/superset-frontend/src/explore/controlPanels/DeckScatter.js b/superset-frontend/src/explore/controlPanels/DeckScatter.js index e00edc94ca68..efb858454724 100644 --- a/superset-frontend/src/explore/controlPanels/DeckScatter.js +++ b/superset-frontend/src/explore/controlPanels/DeckScatter.js @@ -17,7 +17,23 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { validateNonEmpty } from '@superset-ui/validator'; import timeGrainSqlaAnimationOverrides from './timeGrainSqlaAnimationOverrides'; +import { + filterNulls, + autozoom, + dimension, + jsColumns, + jsDataMutator, + jsTooltip, + jsOnclickHref, + legendFormat, + legendPosition, + viewport, + spatial, + pointRadiusFixed, + multiplier, +} from './Shared_DeckGL'; export default { requiresTime: true, @@ -37,8 +53,8 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['spatial', null], - ['row_limit', 'filter_nulls'], + [spatial, null], + ['row_limit', filterNulls], ['adhoc_filters'], ], }, @@ -46,43 +62,100 @@ export default { label: t('Map'), expanded: true, controlSetRows: [ - ['mapbox_style', 'viewport'], - ['autozoom', null], + ['mapbox_style', viewport], + [autozoom, null], ], }, { label: t('Point Size'), controlSetRows: [ - ['point_radius_fixed', 'point_unit'], - ['min_radius', 'max_radius'], - ['multiplier', null], + [ + pointRadiusFixed, + { + name: 'point_unit', + config: { + type: 'SelectControl', + label: t('Point Unit'), + default: 'square_m', + clearable: false, + choices: [ + ['square_m', 'Square meters'], + ['square_km', 'Square kilometers'], + ['square_miles', 'Square miles'], + ['radius_m', 'Radius in meters'], + ['radius_km', 'Radius in kilometers'], + ['radius_miles', 'Radius in miles'], + ], + description: t( + 'The unit of measure for the specified point radius', + ), + }, + }, + ], + [ + { + name: 'min_radius', + config: { + type: 'TextControl', + label: t('Minimum Radius'), + isFloat: true, + validators: [validateNonEmpty], + renderTrigger: true, + default: 2, + description: t( + 'Minimum radius size of the circle, in pixels. As the zoom level changes, this ' + + 'insures that the circle respects this minimum radius.', + ), + }, + }, + { + name: 'max_radius', + config: { + type: 'TextControl', + label: t('Maximum Radius'), + isFloat: true, + validators: [validateNonEmpty], + renderTrigger: true, + default: 250, + description: t( + 'Maxium radius size of the circle, in pixels. As the zoom level changes, this ' + + 'insures that the circle respects this maximum radius.', + ), + }, + }, + ], + [multiplier, null], ], }, { label: t('Point Color'), controlSetRows: [ - ['color_picker', 'legend_position'], - [null, 'legend_format'], - ['dimension', 'color_scheme', 'label_colors'], + ['color_picker', legendPosition], + [null, legendFormat], + [ + { + ...dimension, + label: t('Categorical Color'), + description: t( + 'Pick a dimension from which categorical colors are defined', + ), + }, + 'color_scheme', + 'label_colors', + ], ], }, { label: t('Advanced'), controlSetRows: [ - ['js_columns'], - ['js_data_mutator'], - ['js_tooltip'], - ['js_onclick_href'], + [jsColumns], + [jsDataMutator], + [jsTooltip], + [jsOnclickHref], ], }, ], controlOverrides: { - dimension: { - label: t('Categorical Color'), - description: t( - 'Pick a dimension from which categorical colors are defined', - ), - }, size: { validators: [], }, diff --git a/superset-frontend/src/explore/controlPanels/DeckScreengrid.js b/superset-frontend/src/explore/controlPanels/DeckScreengrid.js index f20c02b42d29..2cd77787db7c 100644 --- a/superset-frontend/src/explore/controlPanels/DeckScreengrid.js +++ b/superset-frontend/src/explore/controlPanels/DeckScreengrid.js @@ -17,8 +17,19 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { nonEmpty } from '../validators'; +import { validateNonEmpty } from '@superset-ui/validator'; import timeGrainSqlaAnimationOverrides from './timeGrainSqlaAnimationOverrides'; +import { + filterNulls, + autozoom, + jsColumns, + jsDataMutator, + jsTooltip, + jsOnclickHref, + gridSize, + viewport, + spatial, +} from './Shared_DeckGL'; export default { requiresTime: true, @@ -27,30 +38,30 @@ export default { label: t('Query'), expanded: true, controlSetRows: [ - ['spatial', 'size'], - ['row_limit', 'filter_nulls'], + [spatial, 'size'], + ['row_limit', filterNulls], ['adhoc_filters'], ], }, { label: t('Map'), controlSetRows: [ - ['mapbox_style', 'viewport'], - ['autozoom', null], + ['mapbox_style', viewport], + [autozoom, null], ], }, { label: t('Grid'), expanded: true, - controlSetRows: [['grid_size', 'color_picker']], + controlSetRows: [[gridSize, 'color_picker']], }, { label: t('Advanced'), controlSetRows: [ - ['js_columns'], - ['js_data_mutator'], - ['js_tooltip'], - ['js_onclick_href'], + [jsColumns], + [jsDataMutator], + [jsTooltip], + [jsOnclickHref], ], }, ], @@ -58,7 +69,7 @@ export default { size: { label: t('Weight'), description: t("Metric used as a weight for the grid's coloring"), - validators: [nonEmpty], + validators: [validateNonEmpty], }, time_grain_sqla: timeGrainSqlaAnimationOverrides, }, diff --git a/superset-frontend/src/explore/controlPanels/DirectedForce.js b/superset-frontend/src/explore/controlPanels/DirectedForce.js index e0fa572df29c..db9a3d2ec485 100644 --- a/superset-frontend/src/explore/controlPanels/DirectedForce.js +++ b/superset-frontend/src/explore/controlPanels/DirectedForce.js @@ -17,6 +17,7 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { formatSelectOptions } from '../../modules/utils'; export default { controlPanelSections: [ @@ -32,7 +33,34 @@ export default { }, { label: t('Options'), - controlSetRows: [['link_length'], ['charge']], + controlSetRows: [ + ['link_length'], + [ + { + name: 'charge', + config: { + type: 'SelectControl', + renderTrigger: true, + freeForm: true, + label: t('Charge'), + default: '-500', + choices: formatSelectOptions([ + '-50', + '-75', + '-100', + '-150', + '-200', + '-250', + '-500', + '-1000', + '-2500', + '-5000', + ]), + description: t('Charge in the force layout'), + }, + }, + ], + ], }, ], controlOverrides: { diff --git a/superset-frontend/src/explore/controlPanels/DistBar.js b/superset-frontend/src/explore/controlPanels/DistBar.js index 06f58dffdacd..5c1ee4ad20c7 100644 --- a/superset-frontend/src/explore/controlPanels/DistBar.js +++ b/superset-frontend/src/explore/controlPanels/DistBar.js @@ -17,7 +17,18 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { nonEmpty } from '../validators'; +import { validateNonEmpty } from '@superset-ui/validator'; +import { + showLegend, + showControls, + xAxisLabel, + bottomMargin, + xTicksLayout, + showBarValue, + barStacked, + reduceXTicks, + yAxisLabel, +} from './Shared_NVD3'; export default { controlPanelSections: [ @@ -38,25 +49,37 @@ export default { expanded: true, controlSetRows: [ ['color_scheme', 'label_colors'], - ['show_legend', 'show_bar_value'], - ['bar_stacked', 'order_bars'], - ['y_axis_format', 'y_axis_label'], - ['show_controls', null], + [showLegend, showBarValue], + [ + barStacked, + { + name: 'order_bars', + config: { + type: 'CheckboxControl', + label: t('Sort Bars'), + default: false, + renderTrigger: true, + description: t('Sort bars by x labels.'), + }, + }, + ], + ['y_axis_format', yAxisLabel], + [showControls, null], ], }, { label: t('X Axis'), expanded: true, controlSetRows: [ - ['x_axis_label', 'bottom_margin'], - ['x_ticks_layout', 'reduce_x_ticks'], + [xAxisLabel, bottomMargin], + [xTicksLayout, reduceXTicks], ], }, ], controlOverrides: { groupby: { label: t('Series'), - validators: [nonEmpty], + validators: [validateNonEmpty], }, columns: { label: t('Breakdowns'), diff --git a/superset-frontend/src/explore/controlPanels/DualLine.js b/superset-frontend/src/explore/controlPanels/DualLine.js index f4b854fc0c12..b3859707a4ee 100644 --- a/superset-frontend/src/explore/controlPanels/DualLine.js +++ b/superset-frontend/src/explore/controlPanels/DualLine.js @@ -19,6 +19,7 @@ import { t } from '@superset-ui/translation'; import { annotations } from './sections'; import { D3_TIME_FORMAT_OPTIONS } from '../controls'; +import { xAxisFormat, yAxis2Format } from './Shared_NVD3'; export default { requiresTime: true, @@ -26,7 +27,7 @@ export default { { label: t('Chart Options'), expanded: true, - controlSetRows: [['color_scheme', 'label_colors'], ['x_axis_format']], + controlSetRows: [['color_scheme', 'label_colors'], [xAxisFormat]], }, { label: t('Y Axis 1'), @@ -36,7 +37,7 @@ export default { { label: t('Y Axis 2'), expanded: true, - controlSetRows: [['metric_2', 'y_axis_2_format']], + controlSetRows: [['metric_2', yAxis2Format]], }, { label: t('Query'), @@ -53,10 +54,6 @@ export default { y_axis_format: { label: t('Left Axis Format'), }, - x_axis_format: { - choices: D3_TIME_FORMAT_OPTIONS, - default: 'smart_date', - }, }, sectionOverrides: { druidTimeSeries: { diff --git a/superset-frontend/src/explore/controlPanels/EventFlow.js b/superset-frontend/src/explore/controlPanels/EventFlow.js index ce2d85f118f6..fd6056b999f6 100644 --- a/superset-frontend/src/explore/controlPanels/EventFlow.js +++ b/superset-frontend/src/explore/controlPanels/EventFlow.js @@ -17,7 +17,8 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { nonEmpty } from '../validators'; +import { validateNonEmpty } from '@superset-ui/validator'; +import { formatSelectOptionsForRange } from '../../modules/utils'; export default { requiresTime: true, @@ -28,8 +29,36 @@ export default { ['entity'], ['all_columns_x'], ['row_limit'], - ['order_by_entity'], - ['min_leaf_node_event_count'], + [ + { + name: 'order_by_entity', + config: { + type: 'CheckboxControl', + label: t('Order by entity id'), + description: t( + 'Important! Select this if the table is not already sorted by entity id, ' + + 'else there is no guarantee that all events for each entity are returned.', + ), + default: true, + }, + }, + ], + [ + { + name: 'min_leaf_node_event_count', + config: { + type: 'SelectControl', + freeForm: false, + label: t('Minimum leaf node event count'), + default: 1, + choices: formatSelectOptionsForRange(1, 10), + description: t( + 'Leaf nodes that represent fewer than this number of events will be initially ' + + 'hidden in the visualization', + ), + }, + }, + ], ], }, { @@ -49,7 +78,7 @@ export default { }, all_columns_x: { label: t('Column containing event names'), - validators: [nonEmpty], + validators: [validateNonEmpty], default: control => control.choices && control.choices.length > 0 ? control.choices[0][0] diff --git a/superset-frontend/src/explore/controlPanels/FilterBox.jsx b/superset-frontend/src/explore/controlPanels/FilterBox.jsx index 7d0262486291..bf6fad6c9bdf 100644 --- a/superset-frontend/src/explore/controlPanels/FilterBox.jsx +++ b/superset-frontend/src/explore/controlPanels/FilterBox.jsx @@ -27,9 +27,69 @@ export default { controlSetRows: [ ['filter_configs'], [
], - ['date_filter', 'instant_filtering'], - ['show_sqla_time_granularity', 'show_sqla_time_column'], - ['show_druid_time_granularity', 'show_druid_time_origin'], + [ + { + name: 'date_filter', + config: { + type: 'CheckboxControl', + label: t('Date Filter'), + default: true, + description: t('Whether to include a time filter'), + }, + }, + { + name: 'instant_filtering', + config: { + type: 'CheckboxControl', + label: t('Instant Filtering'), + renderTrigger: true, + default: true, + description: + 'Whether to apply filters as they change, or wait for ' + + 'users to hit an [Apply] button', + }, + }, + ], + [ + { + name: 'show_sqla_time_granularity', + config: { + type: 'CheckboxControl', + label: t('Show SQL Granularity Dropdown'), + default: false, + description: t('Check to include SQL Granularity dropdown'), + }, + }, + { + name: 'show_sqla_time_column', + config: { + type: 'CheckboxControl', + label: t('Show SQL Time Column'), + default: false, + description: t('Check to include Time Column dropdown'), + }, + }, + ], + [ + { + name: 'show_druid_time_granularity', + config: { + type: 'CheckboxControl', + label: t('Show Druid Granularity Dropdown'), + default: false, + description: t('Check to include Druid Granularity dropdown'), + }, + }, + { + name: 'show_druid_time_origin', + config: { + type: 'CheckboxControl', + label: t('Show Druid Time Origin'), + default: false, + description: t('Check to include Time Origin dropdown'), + }, + }, + ], ['adhoc_filters'], ], }, diff --git a/superset-frontend/src/explore/controlPanels/Heatmap.js b/superset-frontend/src/explore/controlPanels/Heatmap.js index 0b59c67a260d..fd379a1568fe 100644 --- a/superset-frontend/src/explore/controlPanels/Heatmap.js +++ b/superset-frontend/src/explore/controlPanels/Heatmap.js @@ -17,7 +17,18 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { nonEmpty } from '../validators'; +import { validateNonEmpty } from '@superset-ui/validator'; +import { + formatSelectOptionsForRange, + formatSelectOptions, +} from '../../modules/utils'; + +const sortAxisChoices = [ + ['alpha_asc', t('Axis ascending')], + ['alpha_desc', t('Axis descending')], + ['value_asc', t('Metric ascending')], + ['value_desc', t('Metric descending')], +]; export default { controlPanelSections: [ @@ -36,34 +47,180 @@ export default { expanded: true, controlSetRows: [ ['linear_color_scheme'], - ['xscale_interval', 'yscale_interval'], - ['canvas_image_rendering', 'normalize_across'], - ['left_margin', 'bottom_margin'], - ['y_axis_bounds', 'y_axis_format'], - ['show_legend', 'show_perc'], + [ + { + name: 'xscale_interval', + config: { + type: 'SelectControl', + label: t('XScale Interval'), + renderTrigger: true, + choices: formatSelectOptionsForRange(1, 50), + default: '1', + clearable: false, + description: t( + 'Number of steps to take between ticks when displaying the X scale', + ), + }, + }, + { + name: 'yscale_interval', + config: { + type: 'SelectControl', + label: t('YScale Interval'), + choices: formatSelectOptionsForRange(1, 50), + default: '1', + clearable: false, + renderTrigger: true, + description: t( + 'Number of steps to take between ticks when displaying the Y scale', + ), + }, + }, + ], + [ + { + name: 'canvas_image_rendering', + config: { + type: 'SelectControl', + label: t('Rendering'), + renderTrigger: true, + choices: [ + ['pixelated', 'pixelated (Sharp)'], + ['auto', 'auto (Smooth)'], + ], + default: 'pixelated', + description: t( + 'image-rendering CSS attribute of the canvas object that ' + + 'defines how the browser scales up the image', + ), + }, + }, + 'normalize_across', + ], + [ + { + name: 'left_margin', + config: { + type: 'SelectControl', + freeForm: true, + clearable: false, + label: t('Left Margin'), + choices: formatSelectOptions([ + 'auto', + 50, + 75, + 100, + 125, + 150, + 200, + ]), + default: 'auto', + renderTrigger: true, + description: t( + 'Left margin, in pixels, allowing for more room for axis labels', + ), + }, + }, + { + name: 'bottom_margin', + config: { + type: 'SelectControl', + clearable: false, + freeForm: true, + label: t('Bottom Margin'), + choices: formatSelectOptions([ + 'auto', + 50, + 75, + 100, + 125, + 150, + 200, + ]), + default: 'auto', + renderTrigger: true, + description: t( + 'Bottom margin, in pixels, allowing for more room for axis labels', + ), + }, + }, + ], + [ + { + name: 'y_axis_bounds', + config: { + type: 'BoundsControl', + label: t('Value bounds'), + renderTrigger: true, + default: [null, null], + description: t( + 'Hard value bounds applied for color coding. Is only relevant ' + + 'and applied when the normalization is applied against the whole heatmap.', + ), + }, + }, + 'y_axis_format', + ], + [ + { + name: 'show_legend', + config: { + type: 'CheckboxControl', + label: t('Legend'), + renderTrigger: true, + default: true, + description: t('Whether to display the legend (toggles)'), + }, + }, + { + name: 'show_perc', + config: { + type: 'CheckboxControl', + label: t('Show percentage'), + renderTrigger: true, + description: t( + 'Whether to include the percentage in the tooltip', + ), + default: true, + }, + }, + ], ['show_values', 'normalized'], - ['sort_x_axis', 'sort_y_axis'], + [ + { + name: 'sort_x_axis', + config: { + type: 'SelectControl', + label: t('Sort X Axis'), + choices: sortAxisChoices, + clearable: false, + default: 'alpha_asc', + }, + }, + { + name: 'sort_y_axis', + config: { + type: 'SelectControl', + label: t('Sort Y Axis'), + choices: sortAxisChoices, + clearable: false, + default: 'alpha_asc', + }, + }, + ], ], }, ], controlOverrides: { all_columns_x: { - validators: [nonEmpty], + validators: [validateNonEmpty], }, all_columns_y: { - validators: [nonEmpty], + validators: [validateNonEmpty], }, normalized: t( 'Whether to apply a normal distribution based on rank on the color scale', ), - y_axis_bounds: { - label: t('Value bounds'), - renderTrigger: true, - description: t( - 'Hard value bounds applied for color coding. Is only relevant ' + - 'and applied when the normalization is applied against the whole heatmap.', - ), - }, y_axis_format: { label: t('Value Format'), }, diff --git a/superset-frontend/src/explore/controlPanels/Histogram.js b/superset-frontend/src/explore/controlPanels/Histogram.js index d95ff80a2c92..3f5810edd3b0 100644 --- a/superset-frontend/src/explore/controlPanels/Histogram.js +++ b/superset-frontend/src/explore/controlPanels/Histogram.js @@ -17,7 +17,7 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { nonEmpty } from '../validators'; +import { validateNonEmpty } from '@superset-ui/validator'; export default { controlPanelSections: [ @@ -37,7 +37,26 @@ export default { controlSetRows: [ ['color_scheme', 'label_colors'], ['link_length'], - ['x_axis_label', 'y_axis_label'], + [ + { + name: 'x_axis_label', + config: { + type: 'TextControl', + label: t('X Axis Label'), + renderTrigger: true, + default: '', + }, + }, + { + name: 'y_axis_label', + config: { + type: 'TextControl', + label: t('Y Axis Label'), + renderTrigger: true, + default: '', + }, + }, + ], ['global_opacity'], ['normalized'], ], @@ -48,7 +67,7 @@ export default { label: t('Numeric Columns'), description: t('Select the numeric columns to draw the histogram'), multi: true, - validators: [nonEmpty], + validators: [validateNonEmpty], }, link_length: { label: t('No of Bins'), diff --git a/superset-frontend/src/explore/controlPanels/Horizon.js b/superset-frontend/src/explore/controlPanels/Horizon.js index 244980e411a9..983ac9de117e 100644 --- a/superset-frontend/src/explore/controlPanels/Horizon.js +++ b/superset-frontend/src/explore/controlPanels/Horizon.js @@ -18,6 +18,7 @@ */ import { t } from '@superset-ui/translation'; import { NVD3TimeSeries } from './sections'; +import { formatSelectOptions } from '../../modules/utils'; export default { controlPanelSections: [ @@ -25,7 +26,48 @@ export default { { label: t('Chart Options'), expanded: true, - controlSetRows: [['series_height', 'horizon_color_scale']], + controlSetRows: [ + [ + { + name: 'series_height', + config: { + type: 'SelectControl', + renderTrigger: true, + freeForm: true, + label: t('Series Height'), + default: '25', + choices: formatSelectOptions([ + '10', + '25', + '40', + '50', + '75', + '100', + '150', + '200', + ]), + description: t('Pixel height of each series'), + }, + }, + { + name: 'horizon_color_scale', + config: { + type: 'SelectControl', + renderTrigger: true, + label: t('Value Domain'), + choices: [ + ['series', 'series'], + ['overall', 'overall'], + ['change', 'change'], + ], + default: 'series', + description: t( + 'series: Treat each series independently; overall: All series use the same scale; change: Show changes compared to the first data point in each series', + ), + }, + }, + ], + ], }, ], }; diff --git a/superset-frontend/src/explore/controlPanels/Line.js b/superset-frontend/src/explore/controlPanels/Line.js index 201cf08b453a..4da6e4539518 100644 --- a/superset-frontend/src/explore/controlPanels/Line.js +++ b/superset-frontend/src/explore/controlPanels/Line.js @@ -19,6 +19,23 @@ import { t } from '@superset-ui/translation'; import { NVD3TimeSeries, annotations } from './sections'; import { D3_TIME_FORMAT_OPTIONS } from '../controls'; +import { + lineInterpolation, + showBrush, + showLegend, + xAxisLabel, + bottomMargin, + xTicksLayout, + xAxisFormat, + yLogScale, + yAxisBounds, + yAxisLabel, + xAxisShowMinmax, + yAxisShowMinmax, + richTooltip, + leftMargin, + showMarkers, +} from './Shared_NVD3'; export default { requiresTime: true, @@ -29,37 +46,46 @@ export default { expanded: true, controlSetRows: [ ['color_scheme', 'label_colors'], - ['show_brush', 'send_time_range', 'show_legend'], - ['rich_tooltip', 'show_markers'], - ['line_interpolation'], + [ + showBrush, + { + name: 'send_time_range', + config: { + type: 'CheckboxControl', + label: t('Propagate'), + renderTrigger: true, + default: false, + description: t('Send range filter events to other charts'), + }, + }, + showLegend, + ], + [richTooltip, showMarkers], + [lineInterpolation], ], }, { label: t('X Axis'), expanded: true, controlSetRows: [ - ['x_axis_label', 'bottom_margin'], - ['x_ticks_layout', 'x_axis_format'], - ['x_axis_showminmax', null], + [xAxisLabel, bottomMargin], + [xTicksLayout, xAxisFormat], + [xAxisShowMinmax, null], ], }, { label: t('Y Axis'), expanded: true, controlSetRows: [ - ['y_axis_label', 'left_margin'], - ['y_axis_showminmax', 'y_log_scale'], - ['y_axis_format', 'y_axis_bounds'], + [yAxisLabel, leftMargin], + [yAxisShowMinmax, yLogScale], + ['y_axis_format', yAxisBounds], ], }, NVD3TimeSeries[1], annotations, ], controlOverrides: { - x_axis_format: { - choices: D3_TIME_FORMAT_OPTIONS, - default: 'smart_date', - }, row_limit: { default: 50000, }, diff --git a/superset-frontend/src/explore/controlPanels/LineMulti.js b/superset-frontend/src/explore/controlPanels/LineMulti.js index 68ca2609cb4d..b9b6fa3c804c 100644 --- a/superset-frontend/src/explore/controlPanels/LineMulti.js +++ b/superset-frontend/src/explore/controlPanels/LineMulti.js @@ -17,8 +17,20 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { validateNonEmpty } from '@superset-ui/validator'; import { annotations } from './sections'; import { D3_TIME_FORMAT_OPTIONS } from '../controls'; +import { + lineInterpolation, + showLegend, + xAxisLabel, + bottomMargin, + xTicksLayout, + xAxisFormat, + xAxisShowMinmax, + showMarkers, + yAxis2Format, +} from './Shared_NVD3'; export default { requiresTime: true, @@ -28,29 +40,96 @@ export default { expanded: true, controlSetRows: [ ['color_scheme', 'label_colors'], - ['prefix_metric_with_slice_name', null], - ['show_legend', 'show_markers'], - ['line_interpolation', null], + [ + { + name: 'prefix_metric_with_slice_name', + config: { + type: 'CheckboxControl', + label: t('Prefix metric name with slice name'), + default: false, + renderTrigger: true, + }, + }, + null, + ], + [showLegend, showMarkers], + [lineInterpolation, null], ], }, { label: t('X Axis'), expanded: true, controlSetRows: [ - ['x_axis_label', 'bottom_margin'], - ['x_ticks_layout', 'x_axis_format'], - ['x_axis_showminmax', null], + [xAxisLabel, bottomMargin], + [xTicksLayout, xAxisFormat], + [xAxisShowMinmax, null], ], }, { label: t('Y Axis 1'), expanded: true, - controlSetRows: [['line_charts', 'y_axis_format']], + controlSetRows: [ + [ + { + name: 'line_charts', + config: { + type: 'SelectAsyncControl', + multi: true, + label: t('Left Axis chart(s)'), + validators: [validateNonEmpty], + default: [], + description: t('Choose one or more charts for left axis'), + dataEndpoint: + '/sliceasync/api/read?_flt_0_viz_type=line&_flt_7_viz_type=line_multi', + placeholder: t('Select charts'), + onAsyncErrorMessage: t('Error while fetching charts'), + mutator: data => { + if (!data || !data.result) { + return []; + } + return data.result.map(o => ({ + value: o.id, + label: o.slice_name, + })); + }, + }, + }, + 'y_axis_format', + ], + ], }, { label: t('Y Axis 2'), expanded: false, - controlSetRows: [['line_charts_2', 'y_axis_2_format']], + controlSetRows: [ + [ + { + name: 'line_charts_2', + config: { + type: 'SelectAsyncControl', + multi: true, + label: t('Right Axis chart(s)'), + validators: [], + default: [], + description: t('Choose one or more charts for right axis'), + dataEndpoint: + '/sliceasync/api/read?_flt_0_viz_type=line&_flt_7_viz_type=line_multi', + placeholder: t('Select charts'), + onAsyncErrorMessage: t('Error while fetching charts'), + mutator: data => { + if (!data || !data.result) { + return []; + } + return data.result.map(o => ({ + value: o.id, + label: o.slice_name, + })); + }, + }, + }, + yAxis2Format, + ], + ], }, { label: t('Query'), @@ -60,10 +139,6 @@ export default { annotations, ], controlOverrides: { - line_charts: { - label: t('Left Axis chart(s)'), - description: t('Choose one or more charts for left axis'), - }, y_axis_format: { label: t('Left Axis Format'), }, diff --git a/superset-frontend/src/explore/controlPanels/Mapbox.js b/superset-frontend/src/explore/controlPanels/Mapbox.js index f2e4d080d5e8..15f87b85fc90 100644 --- a/superset-frontend/src/explore/controlPanels/Mapbox.js +++ b/superset-frontend/src/explore/controlPanels/Mapbox.js @@ -17,6 +17,8 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { formatSelectOptions } from '../../modules/utils'; +import { columnChoices } from '../controls'; export default { controlPanelSections: [ @@ -25,7 +27,33 @@ export default { expanded: true, controlSetRows: [ ['all_columns_x', 'all_columns_y'], - ['clustering_radius'], + [ + { + name: 'clustering_radius', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Clustering Radius'), + default: '60', + choices: formatSelectOptions([ + '0', + '20', + '40', + '60', + '80', + '100', + '200', + '500', + '1000', + ]), + description: t( + 'The radius (in pixels) the algorithm uses to define a cluster. ' + + 'Choose 0 to turn off clustering, but beware that a large ' + + 'number of points (>1000) will cause lag.', + ), + }, + }, + ], ['row_limit'], ['adhoc_filters'], ['groupby'], @@ -33,7 +61,42 @@ export default { }, { label: t('Points'), - controlSetRows: [['point_radius'], ['point_radius_unit']], + controlSetRows: [ + [ + { + name: 'point_radius', + config: { + type: 'SelectControl', + label: t('Point Radius'), + default: 'Auto', + description: t( + 'The radius of individual points (ones that are not in a cluster). ' + + 'Either a numerical column or `Auto`, which scales the point based ' + + 'on the largest cluster', + ), + mapStateToProps: state => ({ + choices: formatSelectOptions(['Auto']).concat( + columnChoices(state.datasource), + ), + }), + }, + }, + ], + [ + { + name: 'point_radius_unit', + config: { + type: 'SelectControl', + label: t('Point Radius Unit'), + default: 'Pixels', + choices: formatSelectOptions(['Pixels', 'Miles', 'Kilometers']), + description: t( + 'The unit of measure for the specified point radius', + ), + }, + }, + ], + ], }, { label: t('Labelling'), @@ -42,18 +105,94 @@ export default { { label: t('Visual Tweaks'), controlSetRows: [ - ['render_while_dragging'], + [ + { + name: 'render_while_dragging', + config: { + type: 'CheckboxControl', + label: t('Live render'), + default: true, + description: t( + 'Points and clusters will update as the viewport is being changed', + ), + }, + }, + ], ['mapbox_style'], ['global_opacity'], - ['mapbox_color'], + [ + { + name: 'mapbox_color', + config: { + type: 'SelectControl', + freeForm: true, + label: t('RGB Color'), + default: 'rgb(0, 122, 135)', + choices: [ + ['rgb(0, 139, 139)', 'Dark Cyan'], + ['rgb(128, 0, 128)', 'Purple'], + ['rgb(255, 215, 0)', 'Gold'], + ['rgb(69, 69, 69)', 'Dim Gray'], + ['rgb(220, 20, 60)', 'Crimson'], + ['rgb(34, 139, 34)', 'Forest Green'], + ], + description: t('The color for points and clusters in RGB'), + }, + }, + ], ], }, { label: t('Viewport'), expanded: true, controlSetRows: [ - ['viewport_longitude', 'viewport_latitude'], - ['viewport_zoom', null], + [ + { + name: 'viewport_longitude', + config: { + type: 'TextControl', + label: t('Default longitude'), + renderTrigger: true, + default: -122.405293, + isFloat: true, + description: t('Longitude of default viewport'), + places: 8, + // Viewport longitude changes shouldn't prompt user to re-run query + dontRefreshOnChange: true, + }, + }, + { + name: 'viewport_latitude', + config: { + type: 'TextControl', + label: t('Default latitude'), + renderTrigger: true, + default: 37.772123, + isFloat: true, + description: t('Latitude of default viewport'), + places: 8, + // Viewport latitude changes shouldn't prompt user to re-run query + dontRefreshOnChange: true, + }, + }, + ], + [ + { + name: 'viewport_zoom', + config: { + type: 'TextControl', + label: t('Zoom'), + renderTrigger: true, + isFloat: true, + default: 11, + description: t('Zoom level of the map'), + places: 8, + // Viewport zoom shouldn't prompt user to re-run query + dontRefreshOnChange: true, + }, + }, + null, + ], ], }, ], @@ -73,13 +212,6 @@ export default { 'in each cluster to produce the cluster label.', ), }, - rich_tooltip: { - label: t('Tooltip'), - description: t( - 'Show a tooltip when hovering over points and clusters ' + - 'describing the label', - ), - }, groupby: { description: t( 'One or many controls to group by. If grouping, latitude ' + diff --git a/superset-frontend/src/explore/controlPanels/Para.js b/superset-frontend/src/explore/controlPanels/Para.js index f6254ce7867d..31526f8040d4 100644 --- a/superset-frontend/src/explore/controlPanels/Para.js +++ b/superset-frontend/src/explore/controlPanels/Para.js @@ -35,7 +35,28 @@ export default { label: t('Options'), expanded: true, controlSetRows: [ - ['show_datatable', 'include_series'], + [ + { + name: 'show_datatable', + config: { + type: 'CheckboxControl', + label: t('Data Table'), + default: false, + renderTrigger: true, + description: t('Whether to display the interactive data table'), + }, + }, + { + name: 'include_series', + config: { + type: 'CheckboxControl', + label: t('Include Series'), + renderTrigger: true, + default: false, + description: t('Include series name as an axis'), + }, + }, + ], ['linear_color_scheme'], ], }, diff --git a/superset-frontend/src/explore/controlPanels/Partition.jsx b/superset-frontend/src/explore/controlPanels/Partition.jsx new file mode 100644 index 000000000000..86089d87a5c1 --- /dev/null +++ b/superset-frontend/src/explore/controlPanels/Partition.jsx @@ -0,0 +1,164 @@ +/** + * 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 { t } from '@superset-ui/translation'; +import { validateNonEmpty } from '@superset-ui/validator'; +import { NVD3TimeSeries } from './sections'; +import OptionDescription from '../../components/OptionDescription'; + +export default { + controlPanelSections: [ + NVD3TimeSeries[0], + { + label: t('Time Series Options'), + expanded: true, + controlSetRows: [ + [ + { + name: 'time_series_option', + config: { + type: 'SelectControl', + label: t('Options'), + validators: [validateNonEmpty], + default: 'not_time', + valueKey: 'value', + options: [ + { + label: t('Not Time Series'), + value: 'not_time', + description: t('Ignore time'), + }, + { + label: t('Time Series'), + value: 'time_series', + description: t('Standard time series'), + }, + { + label: t('Aggregate Mean'), + value: 'agg_mean', + description: t('Mean of values over specified period'), + }, + { + label: t('Aggregate Sum'), + value: 'agg_sum', + description: t('Sum of values over specified period'), + }, + { + label: t('Difference'), + value: 'point_diff', + description: t( + 'Metric change in value from `since` to `until`', + ), + }, + { + label: t('Percent Change'), + value: 'point_percent', + description: t( + 'Metric percent change in value from `since` to `until`', + ), + }, + { + label: t('Factor'), + value: 'point_factor', + description: t( + 'Metric factor change from `since` to `until`', + ), + }, + { + label: t('Advanced Analytics'), + value: 'adv_anal', + description: t('Use the Advanced Analytics options below'), + }, + ], + optionRenderer: op => , + valueRenderer: op => , + description: t('Settings for time series'), + }, + }, + ], + ], + }, + { + label: t('Chart Options'), + expanded: true, + controlSetRows: [ + ['color_scheme', 'label_colors'], + ['number_format', 'date_time_format'], + [ + { + name: 'partition_limit', + config: { + type: 'TextControl', + label: t('Partition Limit'), + isInt: true, + default: '5', + description: t( + 'The maximum number of subdivisions of each group; ' + + 'lower values are pruned first', + ), + }, + }, + { + name: 'partition_threshold', + config: { + type: 'TextControl', + label: t('Partition Threshold'), + isFloat: true, + default: '0.05', + description: t( + 'Partitions whose height to parent height proportions are ' + + 'below this value are pruned', + ), + }, + }, + ], + [ + 'log_scale', + { + name: 'equal_date_size', + config: { + type: 'CheckboxControl', + label: t('Equal Date Sizes'), + default: true, + renderTrigger: true, + description: t( + 'Check to force date partitions to have the same height', + ), + }, + }, + ], + [ + { + name: 'rich_tooltip', + config: { + type: 'CheckboxControl', + label: t('Rich Tooltip'), + renderTrigger: true, + default: true, + description: t( + 'The rich tooltip shows a list of all series for that point in time', + ), + }, + }, + ], + ], + }, + NVD3TimeSeries[1], + ], +}; diff --git a/superset-frontend/src/explore/controlPanels/Pie.js b/superset-frontend/src/explore/controlPanels/Pie.js index d9673bb38d87..fed9e01721c4 100644 --- a/superset-frontend/src/explore/controlPanels/Pie.js +++ b/superset-frontend/src/explore/controlPanels/Pie.js @@ -17,6 +17,7 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { showLegend } from './Shared_NVD3'; export default { controlPanelSections: [ @@ -34,9 +35,64 @@ export default { label: t('Chart Options'), expanded: true, controlSetRows: [ - ['pie_label_type', 'number_format'], - ['donut', 'show_legend'], - ['show_labels', 'labels_outside'], + [ + { + name: 'pie_label_type', + config: { + type: 'SelectControl', + label: t('Label Type'), + default: 'key', + renderTrigger: true, + choices: [ + ['key', 'Category Name'], + ['value', 'Value'], + ['percent', 'Percentage'], + ['key_value', 'Category and Value'], + ['key_percent', 'Category and Percentage'], + ], + description: t('What should be shown on the label?'), + }, + }, + 'number_format', + ], + [ + { + name: 'donut', + config: { + type: 'CheckboxControl', + label: t('Donut'), + default: false, + renderTrigger: true, + description: t('Do you want a donut or a pie?'), + }, + }, + showLegend, + ], + [ + { + name: 'show_labels', + config: { + type: 'CheckboxControl', + label: t('Show Labels'), + renderTrigger: true, + default: true, + description: t( + 'Whether to display the labels. Note that the label only displays when the the 5% ' + + 'threshold.', + ), + }, + }, + { + name: 'labels_outside', + config: { + type: 'CheckboxControl', + label: t('Put labels outside'), + default: true, + renderTrigger: true, + description: t('Put the labels outside the pie?'), + }, + }, + ], ['color_scheme', 'label_colors'], ], }, diff --git a/superset-frontend/src/explore/controlPanels/PivotTable.js b/superset-frontend/src/explore/controlPanels/PivotTable.js index 4ed26fa330e1..5451f0cce809 100644 --- a/superset-frontend/src/explore/controlPanels/PivotTable.js +++ b/superset-frontend/src/explore/controlPanels/PivotTable.js @@ -34,9 +34,45 @@ export default { { label: t('Pivot Options'), controlSetRows: [ - ['pandas_aggfunc', 'pivot_margins'], - ['number_format', 'combine_metric'], - ['transpose_pivot'], + [ + 'pandas_aggfunc', + { + name: 'pivot_margins', + config: { + type: 'CheckboxControl', + label: t('Show totals'), + renderTrigger: false, + default: true, + description: t('Display total row/column'), + }, + }, + ], + [ + 'number_format', + { + name: 'combine_metric', + config: { + type: 'CheckboxControl', + label: t('Combine Metrics'), + default: false, + description: t( + 'Display metrics side by side within each column, as ' + + 'opposed to each column being displayed side by side for each metric.', + ), + }, + }, + ], + [ + { + name: 'transpose_pivot', + config: { + type: 'CheckboxControl', + label: t('Transpose Pivot'), + default: false, + description: t('Swap Groups and Columns'), + }, + }, + ], ], }, ], diff --git a/superset-frontend/src/explore/controlPanels/Rose.js b/superset-frontend/src/explore/controlPanels/Rose.js index 854985aae32f..147bd24a7812 100644 --- a/superset-frontend/src/explore/controlPanels/Rose.js +++ b/superset-frontend/src/explore/controlPanels/Rose.js @@ -30,7 +30,18 @@ export default { ['color_scheme', 'label_colors'], ['number_format', 'date_time_format'], [ - 'rich_tooltip', + { + name: 'rich_tooltip', + config: { + type: 'CheckboxControl', + label: t('Rich Tooltip'), + renderTrigger: true, + default: true, + description: t( + 'The rich tooltip shows a list of all series for that point in time', + ), + }, + }, { name: 'rose_area_proportion', config: { diff --git a/superset-frontend/src/explore/controlPanels/Shared_BigNumber.js b/superset-frontend/src/explore/controlPanels/Shared_BigNumber.js new file mode 100644 index 000000000000..a4df9bcede6d --- /dev/null +++ b/superset-frontend/src/explore/controlPanels/Shared_BigNumber.js @@ -0,0 +1,89 @@ +/** + * 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. + */ + +// These are control configurations that are shared ONLY within the BigNumber viz plugin repo. +import { t } from '@superset-ui/translation'; + +export const headerFontSize = { + name: 'header_font_size', + config: { + type: 'SelectControl', + label: t('Big Number Font Size'), + renderTrigger: true, + clearable: false, + default: 0.4, + // Values represent the percentage of space a header should take + options: [ + { + label: t('Tiny'), + value: 0.2, + }, + { + label: t('Small'), + value: 0.3, + }, + { + label: t('Normal'), + value: 0.4, + }, + { + label: t('Large'), + value: 0.5, + }, + { + label: t('Huge'), + value: 0.6, + }, + ], + }, +}; + +export const subheaderFontSize = { + name: 'subheader_font_size', + config: { + type: 'SelectControl', + label: t('Subheader Font Size'), + renderTrigger: true, + clearable: false, + default: 0.15, + // Values represent the percentage of space a subheader should take + options: [ + { + label: t('Tiny'), + value: 0.125, + }, + { + label: t('Small'), + value: 0.15, + }, + { + label: t('Normal'), + value: 0.2, + }, + { + label: t('Large'), + value: 0.3, + }, + { + label: t('Huge'), + value: 0.4, + }, + ], + }, +}; diff --git a/superset-frontend/src/explore/controlPanels/Shared_DeckGL.jsx b/superset-frontend/src/explore/controlPanels/Shared_DeckGL.jsx new file mode 100644 index 000000000000..ddadb47f4efb --- /dev/null +++ b/superset-frontend/src/explore/controlPanels/Shared_DeckGL.jsx @@ -0,0 +1,394 @@ +/** + * 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. + */ + +// These are control configurations that are shared ONLY within the DeckGL viz plugin repo. + +import React from 'react'; +import { t } from '@superset-ui/translation'; +import { validateNonEmpty } from '@superset-ui/validator'; +import ColumnOption from '../../components/ColumnOption'; +import { D3_FORMAT_OPTIONS, columnChoices, PRIMARY_COLOR } from '../controls'; +import { DEFAULT_VIEWPORT } from '../../explore/components/controls/ViewportControl'; + +const timeColumnOption = { + verbose_name: 'Time', + column_name: '__timestamp', + description: t( + 'A reference to the [Time] configuration, taking granularity into ' + + 'account', + ), +}; + +const groupByControl = { + type: 'SelectControl', + multi: true, + freeForm: true, + label: t('Group by'), + default: [], + includeTime: false, + description: t('One or many controls to group by'), + optionRenderer: c => , + valueRenderer: c => , + valueKey: 'column_name', + allowAll: true, + filterOption: (opt, text) => + (opt.column_name && + opt.column_name.toLowerCase().indexOf(text.toLowerCase()) >= 0) || + (opt.verbose_name && + opt.verbose_name.toLowerCase().indexOf(text.toLowerCase()) >= 0), + promptTextCreator: label => label, + mapStateToProps: (state, control) => { + const newState = {}; + if (state.datasource) { + newState.options = state.datasource.columns.filter(c => c.groupby); + if (control && control.includeTime) { + newState.options.push(timeColumnOption); + } + } + return newState; + }, + commaChoosesOption: false, +}; + +const sandboxUrl = + 'https://github.com/apache/incubator-superset/' + + 'blob/master/superset-frontend/src/modules/sandbox.js'; +const jsFunctionInfo = ( +
+ {t( + 'For more information about objects are in context in the scope of this function, refer to the', + )} + {t(" source code of Superset's sandboxed parser")}. + . +
+); + +function jsFunctionControl( + label, + description, + extraDescr = null, + height = 100, + defaultText = '', +) { + return { + type: 'TextAreaControl', + language: 'javascript', + label, + description, + height, + default: defaultText, + aboveEditorSection: ( +
+

{description}

+

{jsFunctionInfo}

+ {extraDescr} +
+ ), + mapStateToProps: state => ({ + warning: !state.common.conf.ENABLE_JAVASCRIPT_CONTROLS + ? t( + 'This functionality is disabled in your environment for security reasons.', + ) + : null, + readOnly: !state.common.conf.ENABLE_JAVASCRIPT_CONTROLS, + }), + }; +} + +export const filterNulls = { + name: 'filter_nulls', + config: { + type: 'CheckboxControl', + label: t('Ignore null locations'), + default: true, + description: t('Whether to ignore locations that are null'), + }, +}; + +export const autozoom = { + name: 'autozoom', + config: { + type: 'CheckboxControl', + label: t('Auto Zoom'), + default: true, + renderTrigger: true, + description: t( + 'When checked, the map will zoom to your data after each query', + ), + }, +}; + +export const dimension = { + name: 'dimension', + config: { + ...groupByControl, + label: t('Dimension'), + description: t('Select a dimension'), + multi: false, + default: null, + }, +}; + +export const jsColumns = { + name: 'js_columns', + config: { + ...groupByControl, + label: t('Extra data for JS'), + default: [], + description: t( + 'List of extra columns made available in Javascript functions', + ), + }, +}; + +export const jsDataMutator = { + name: 'js_data_mutator', + config: jsFunctionControl( + t('Javascript data interceptor'), + t( + 'Define a javascript function that receives the data array used in the visualization ' + + 'and is expected to return a modified version of that array. This can be used ' + + 'to alter properties of the data, filter, or enrich the array.', + ), + ), +}; + +export const jsTooltip = { + name: 'js_tooltip', + config: jsFunctionControl( + t('Javascript tooltip generator'), + t( + 'Define a function that receives the input and outputs the content for a tooltip', + ), + ), +}; + +export const jsOnclickHref = { + name: 'js_onclick_href', + config: jsFunctionControl( + t('Javascript onClick href'), + t('Define a function that returns a URL to navigate to when user clicks'), + ), +}; + +export const legendFormat = { + name: 'legend_format', + config: { + label: t('Legend Format'), + description: t('Choose the format for legend values'), + type: 'SelectControl', + clearable: false, + default: D3_FORMAT_OPTIONS[0], + choices: D3_FORMAT_OPTIONS, + renderTrigger: true, + }, +}; + +export const legendPosition = { + name: 'legend_position', + config: { + label: t('Legend Position'), + description: t('Choose the position of the legend'), + type: 'SelectControl', + clearable: false, + default: 'tr', + choices: [ + [null, 'None'], + ['tl', 'Top left'], + ['tr', 'Top right'], + ['bl', 'Bottom left'], + ['br', 'Bottom right'], + ], + renderTrigger: true, + }, +}; + +export const lineColumn = { + name: 'line_column', + config: { + type: 'SelectControl', + label: t('Lines column'), + default: null, + description: t('The database columns that contains lines information'), + mapStateToProps: state => ({ + choices: columnChoices(state.datasource), + }), + validators: [validateNonEmpty], + }, +}; + +export const lineWidth = { + name: 'line_width', + config: { + type: 'TextControl', + label: t('Line width'), + renderTrigger: true, + isInt: true, + default: 10, + description: t('The width of the lines'), + }, +}; + +export const fillColorPicker = { + name: 'fill_color_picker', + config: { + label: t('Fill Color'), + description: t( + ' Set the opacity to 0 if you do not want to override the color specified in the GeoJSON', + ), + type: 'ColorPickerControl', + default: PRIMARY_COLOR, + renderTrigger: true, + }, +}; + +export const strokeColorPicker = { + name: 'stroke_color_picker', + config: { + label: t('Stroke Color'), + description: t( + ' Set the opacity to 0 if you do not want to override the color specified in the GeoJSON', + ), + type: 'ColorPickerControl', + default: PRIMARY_COLOR, + renderTrigger: true, + }, +}; + +export const filled = { + name: 'filled', + config: { + type: 'CheckboxControl', + label: t('Filled'), + renderTrigger: true, + description: t('Whether to fill the objects'), + default: true, + }, +}; + +export const stroked = { + name: 'stroked', + config: { + type: 'CheckboxControl', + label: t('Stroked'), + renderTrigger: true, + description: t('Whether to display the stroke'), + default: false, + }, +}; + +export const extruded = { + name: 'extruded', + config: { + type: 'CheckboxControl', + label: t('Extruded'), + renderTrigger: true, + default: true, + description: 'Whether to make the grid 3D', + }, +}; + +export const gridSize = { + name: 'grid_size', + config: { + type: 'TextControl', + label: t('Grid Size'), + renderTrigger: true, + default: 20, + isInt: true, + description: t('Defines the grid size in pixels'), + }, +}; + +export const viewport = { + name: 'viewport', + config: { + type: 'ViewportControl', + label: t('Viewport'), + renderTrigger: false, + description: t('Parameters related to the view and perspective on the map'), + // default is whole world mostly centered + default: DEFAULT_VIEWPORT, + // Viewport changes shouldn't prompt user to re-run query + dontRefreshOnChange: true, + }, +}; + +export const spatial = { + name: 'spatial', + config: { + type: 'SpatialControl', + label: t('Longitude & Latitude'), + validators: [validateNonEmpty], + description: t('Point to your spatial columns'), + mapStateToProps: state => ({ + choices: columnChoices(state.datasource), + }), + }, +}; + +export const pointRadiusFixed = { + name: 'point_radius_fixed', + config: { + type: 'FixedOrMetricControl', + label: t('Point Size'), + default: { type: 'fix', value: 1000 }, + description: t('Fixed point radius'), + mapStateToProps: state => ({ + datasource: state.datasource, + }), + }, +}; + +export const multiplier = { + name: 'multiplier', + config: { + type: 'TextControl', + label: t('Multiplier'), + isFloat: true, + renderTrigger: true, + default: 1, + description: t('Factor to multiply the metric by'), + }, +}; + +export const lineType = { + name: 'line_type', + config: { + type: 'SelectControl', + label: t('Lines encoding'), + clearable: false, + default: 'json', + description: t('The encoding format of the lines'), + choices: [ + ['polyline', 'Polyline'], + ['json', 'JSON'], + ['geohash', 'geohash (square)'], + ], + }, +}; + +export const reverseLongLat = { + name: 'reverse_long_lat', + config: { + type: 'CheckboxControl', + label: t('Reverse Lat & Long'), + default: false, + }, +}; diff --git a/superset-frontend/src/explore/controlPanels/Shared_NVD3.js b/superset-frontend/src/explore/controlPanels/Shared_NVD3.js new file mode 100644 index 000000000000..ea4398b5ce14 --- /dev/null +++ b/superset-frontend/src/explore/controlPanels/Shared_NVD3.js @@ -0,0 +1,309 @@ +/** + * 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. + */ + +// These are control configurations that are shared ONLY within the DeckGL viz plugin repo. + +import React from 'react'; +import { t } from '@superset-ui/translation'; +import { formatSelectOptions } from '../../modules/utils'; +import { + D3_TIME_FORMAT_OPTIONS, + D3_FORMAT_DOCS, + D3_FORMAT_OPTIONS, +} from '../controls'; + +/* + Plugins in question: + + AreaChartPlugin, + BarChartPlugin, + BubbleChartPlugin, + BulletChartPlugin, + CompareChartPlugin, + DistBarChartPlugin, + DualLineChartPlugin, + LineChartPlugin, + LineMultiChartPlugin, + PieChartPlugin, + TimePivotChartPlugin, +*/ + +export const yAxis2Format = { + name: 'y_axis_2_format', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Right Axis Format'), + default: 'SMART_NUMBER', + choices: D3_FORMAT_OPTIONS, + description: D3_FORMAT_DOCS, + }, +}; + +export const showMarkers = { + name: 'show_markers', + config: { + type: 'CheckboxControl', + label: t('Show Markers'), + renderTrigger: true, + default: false, + description: t('Show data points as circle markers on the lines'), + }, +}; + +export const leftMargin = { + name: 'left_margin', + config: { + type: 'SelectControl', + freeForm: true, + clearable: false, + label: t('Left Margin'), + choices: formatSelectOptions(['auto', 50, 75, 100, 125, 150, 200]), + default: 'auto', + renderTrigger: true, + description: t( + 'Left margin, in pixels, allowing for more room for axis labels', + ), + }, +}; + +export const yAxisShowMinmax = { + name: 'y_axis_showminmax', + config: { + type: 'CheckboxControl', + label: t('Y bounds'), + renderTrigger: true, + default: false, + description: t('Whether to display the min and max values of the Y-axis'), + }, +}; + +export const lineInterpolation = { + name: 'line_interpolation', + config: { + type: 'SelectControl', + label: t('Line Style'), + renderTrigger: true, + choices: formatSelectOptions([ + 'linear', + 'basis', + 'cardinal', + 'monotone', + 'step-before', + 'step-after', + ]), + default: 'linear', + description: t('Line interpolation as defined by d3.js'), + }, +}; + +export const showBrush = { + name: 'show_brush', + config: { + type: 'SelectControl', + label: t('Show Range Filter'), + renderTrigger: true, + clearable: false, + default: 'auto', + choices: [ + ['yes', 'Yes'], + ['no', 'No'], + ['auto', 'Auto'], + ], + description: t('Whether to display the time range interactive selector'), + }, +}; + +export const showLegend = { + name: 'show_legend', + config: { + type: 'CheckboxControl', + label: t('Legend'), + renderTrigger: true, + default: true, + description: t('Whether to display the legend (toggles)'), + }, +}; + +export const showControls = { + name: 'show_controls', + config: { + type: 'CheckboxControl', + label: t('Extra Controls'), + renderTrigger: true, + default: false, + description: t( + 'Whether to show extra controls or not. Extra controls ' + + 'include things like making mulitBar charts stacked ' + + 'or side by side.', + ), + }, +}; + +export const xAxisLabel = { + name: 'x_axis_label', + config: { + type: 'TextControl', + label: t('X Axis Label'), + renderTrigger: true, + default: '', + }, +}; + +export const bottomMargin = { + name: 'bottom_margin', + config: { + type: 'SelectControl', + clearable: false, + freeForm: true, + label: t('Bottom Margin'), + choices: formatSelectOptions(['auto', 50, 75, 100, 125, 150, 200]), + default: 'auto', + renderTrigger: true, + description: t( + 'Bottom margin, in pixels, allowing for more room for axis labels', + ), + }, +}; + +export const xTicksLayout = { + name: 'x_ticks_layout', + config: { + type: 'SelectControl', + label: t('X Tick Layout'), + choices: formatSelectOptions(['auto', 'flat', '45°', 'staggered']), + default: 'auto', + clearable: false, + renderTrigger: true, + description: t('The way the ticks are laid out on the X-axis'), + }, +}; + +export const xAxisFormat = { + name: 'x_axis_format', + config: { + type: 'SelectControl', + freeForm: true, + label: t('X Axis Format'), + renderTrigger: true, + choices: D3_TIME_FORMAT_OPTIONS, + default: 'smart_date', + description: D3_FORMAT_DOCS, + }, +}; + +export const yLogScale = { + name: 'y_log_scale', + config: { + type: 'CheckboxControl', + label: t('Y Log Scale'), + default: false, + renderTrigger: true, + description: t('Use a log scale for the Y-axis'), + }, +}; + +export const yAxisBounds = { + name: 'y_axis_bounds', + config: { + type: 'BoundsControl', + label: t('Y Axis Bounds'), + renderTrigger: true, + default: [null, null], + description: t( + 'Bounds for the Y-axis. When left empty, the bounds are ' + + 'dynamically defined based on the min/max of the data. Note that ' + + "this feature will only expand the axis range. It won't " + + "narrow the data's extent.", + ), + }, +}; + +export const xAxisShowMinmax = { + name: 'x_axis_showminmax', + config: { + type: 'CheckboxControl', + label: t('X bounds'), + renderTrigger: true, + default: false, + description: t('Whether to display the min and max values of the X-axis'), + }, +}; + +export const richTooltip = { + name: 'rich_tooltip', + config: { + type: 'CheckboxControl', + label: t('Rich Tooltip'), + renderTrigger: true, + default: true, + description: t( + 'The rich tooltip shows a list of all series for that point in time', + ), + }, +}; + +export const showBarValue = { + name: 'show_bar_value', + config: { + type: 'CheckboxControl', + label: t('Bar Values'), + default: false, + renderTrigger: true, + description: t('Show the value on top of the bar'), + }, +}; + +export const barStacked = { + name: 'bar_stacked', + config: { + type: 'CheckboxControl', + label: t('Stacked Bars'), + renderTrigger: true, + default: false, + description: null, + }, +}; + +export const reduceXTicks = { + name: 'reduce_x_ticks', + config: { + type: 'CheckboxControl', + label: t('Reduce X ticks'), + renderTrigger: true, + default: false, + description: t( + 'Reduces the number of X-axis ticks to be rendered. ' + + 'If true, the x-axis will not overflow and labels may be ' + + 'missing. If false, a minimum width will be applied ' + + 'to columns and the width may overflow into an ' + + 'horizontal scroll.', + ), + }, +}; + +export const yAxisLabel = { + name: 'y_axis_label', + config: { + type: 'TextControl', + label: t('Y Axis Label'), + renderTrigger: true, + default: '', + }, +}; diff --git a/superset-frontend/src/explore/controlPanels/Table.js b/superset-frontend/src/explore/controlPanels/Table.js index 6463cfef1e13..69d754193393 100644 --- a/superset-frontend/src/explore/controlPanels/Table.js +++ b/superset-frontend/src/explore/controlPanels/Table.js @@ -17,6 +17,9 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { validateNonEmpty } from '@superset-ui/validator'; +import { D3_TIME_FORMAT_OPTIONS } from '../controls'; +import { formatSelectOptions } from '../../modules/utils'; export default { controlPanelSections: [ @@ -27,16 +30,71 @@ export default { controlSetRows: [ ['groupby'], ['metrics'], - ['percent_metrics'], + [ + { + name: 'percent_metrics', + config: { + type: 'MetricsControl', + multi: true, + mapStateToProps: state => { + const datasource = state.datasource; + return { + columns: datasource ? datasource.columns : [], + savedMetrics: datasource ? datasource.metrics : [], + datasourceType: datasource && datasource.type, + }; + }, + default: [], + label: t('Percentage Metrics'), + validators: [], + description: t( + 'Metrics for which percentage of total are to be displayed', + ), + }, + }, + ], ['timeseries_limit_metric', 'row_limit'], - ['include_time', 'order_desc'], + [ + { + name: 'include_time', + config: { + type: 'CheckboxControl', + label: t('Include Time'), + description: t( + 'Whether to include the time granularity as defined in the time section', + ), + default: false, + }, + }, + 'order_desc', + ], ], }, { label: t('NOT GROUPED BY'), description: t('Use this section if you want to query atomic rows'), expanded: true, - controlSetRows: [['all_columns'], ['order_by_cols'], ['row_limit', null]], + controlSetRows: [ + ['all_columns'], + [ + { + name: 'order_by_cols', + config: { + type: 'SelectControl', + multi: true, + label: t('Ordering'), + default: [], + description: t('One or many metrics to display'), + mapStateToProps: state => ({ + choices: state.datasource + ? state.datasource.order_by_choices + : [], + }), + }, + }, + ], + ['row_limit', null], + ], }, { label: t('Query'), @@ -47,10 +105,99 @@ export default { label: t('Options'), expanded: true, controlSetRows: [ - ['table_timestamp_format'], - ['page_length', null], - ['include_search', 'table_filter'], - ['align_pn', 'color_pn'], + [ + { + name: 'table_timestamp_format', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Table Timestamp Format'), + default: '%Y-%m-%d %H:%M:%S', + renderTrigger: true, + validators: [validateNonEmpty], + clearable: false, + choices: D3_TIME_FORMAT_OPTIONS, + description: t('Timestamp Format'), + }, + }, + ], + [ + { + name: 'page_length', + config: { + type: 'SelectControl', + freeForm: true, + renderTrigger: true, + label: t('Page Length'), + default: 0, + choices: formatSelectOptions([ + 0, + 10, + 25, + 40, + 50, + 75, + 100, + 150, + 200, + ]), + description: t('Rows per page, 0 means no pagination'), + }, + }, + null, + ], + [ + { + name: 'include_search', + config: { + type: 'CheckboxControl', + label: t('Search Box'), + renderTrigger: true, + default: false, + description: t('Whether to include a client-side search box'), + }, + }, + 'table_filter', + ], + [ + { + name: 'align_pn', + config: { + type: 'CheckboxControl', + label: t('Align +/-'), + renderTrigger: true, + default: false, + description: t( + 'Whether to align the background chart for +/- values', + ), + }, + }, + { + name: 'color_pn', + config: { + type: 'CheckboxControl', + label: t('Color +/-'), + renderTrigger: true, + default: true, + description: t('Whether to color +/- values'), + }, + }, + ], + [ + { + name: 'show_cell_bars', + config: { + type: 'CheckboxControl', + label: t('Show Cell Bars'), + renderTrigger: true, + default: true, + description: t( + 'Enable to display bar chart background elements in table columns', + ), + }, + }, + null, + ], ], }, ], diff --git a/superset-frontend/src/explore/controlPanels/TimePivot.js b/superset-frontend/src/explore/controlPanels/TimePivot.js index 82604ebde17f..35929109589b 100644 --- a/superset-frontend/src/explore/controlPanels/TimePivot.js +++ b/superset-frontend/src/explore/controlPanels/TimePivot.js @@ -17,7 +17,20 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { D3_TIME_FORMAT_OPTIONS } from '../controls'; +import { D3_FORMAT_OPTIONS } from '../controls'; +import { + lineInterpolation, + showLegend, + xAxisLabel, + bottomMargin, + xAxisFormat, + yLogScale, + yAxisBounds, + xAxisShowMinmax, + yAxisShowMinmax, + yAxisLabel, + leftMargin, +} from './Shared_NVD3'; export default { requiresTime: true, @@ -25,13 +38,46 @@ export default { { label: t('Query'), expanded: true, - controlSetRows: [['metric'], ['adhoc_filters'], ['freq']], + controlSetRows: [ + ['metric'], + ['adhoc_filters'], + [ + { + name: 'freq', + config: { + type: 'SelectControl', + label: t('Frequency'), + default: 'W-MON', + freeForm: true, + clearable: false, + choices: [ + ['AS', 'Year (freq=AS)'], + ['52W-MON', '52 weeks starting Monday (freq=52W-MON)'], + ['W-SUN', '1 week starting Sunday (freq=W-SUN)'], + ['W-MON', '1 week starting Monday (freq=W-MON)'], + ['D', 'Day (freq=D)'], + ['4W-MON', '4 weeks (freq=4W-MON)'], + ], + description: t( + `The periodicity over which to pivot time. Users can provide + "Pandas" offset alias. + Click on the info bubble for more details on accepted "freq" expressions.`, + ), + tooltipOnClick: () => { + window.open( + 'https://pandas.pydata.org/pandas-docs/stable/timeseries.html#offset-aliases', + ); + }, + }, + }, + ], + ], }, { label: t('Chart Options'), expanded: true, controlSetRows: [ - ['show_legend', 'line_interpolation'], + [showLegend, lineInterpolation], ['color_picker', null], ], }, @@ -39,25 +85,31 @@ export default { label: t('X Axis'), expanded: true, controlSetRows: [ - ['x_axis_label', 'bottom_margin'], - ['x_axis_showminmax', 'x_axis_format'], + [xAxisLabel, bottomMargin], + [ + xAxisShowMinmax, + { + name: xAxisFormat.name, + config: { + ...xAxisFormat.config, + default: 'SMART_NUMBER', + choices: D3_FORMAT_OPTIONS, + }, + }, + ], ], }, { label: t('Y Axis'), expanded: true, controlSetRows: [ - ['y_axis_label', 'left_margin'], - ['y_axis_showminmax', 'y_log_scale'], - ['y_axis_format', 'y_axis_bounds'], + [yAxisLabel, leftMargin], + [yAxisShowMinmax, yLogScale], + ['y_axis_format', yAxisBounds], ], }, ], controlOverrides: { - x_axis_format: { - choices: D3_TIME_FORMAT_OPTIONS, - default: 'smart_date', - }, metric: { clearable: false, }, diff --git a/superset-frontend/src/explore/controlPanels/Treemap.js b/superset-frontend/src/explore/controlPanels/Treemap.js index 8342ee55b5de..d4b0dcc9058a 100644 --- a/superset-frontend/src/explore/controlPanels/Treemap.js +++ b/superset-frontend/src/explore/controlPanels/Treemap.js @@ -35,7 +35,19 @@ export default { expanded: true, controlSetRows: [ ['color_scheme', 'label_colors'], - ['treemap_ratio'], + [ + { + name: 'treemap_ratio', + config: { + type: 'TextControl', + label: t('Ratio'), + renderTrigger: true, + isFloat: true, + default: 0.5 * (1 + Math.sqrt(5)), // d3 default, golden ratio + description: t('Target aspect ratio for treemap tiles.'), + }, + }, + ], ['number_format'], ], }, diff --git a/superset-frontend/src/explore/controlPanels/WordCloud.js b/superset-frontend/src/explore/controlPanels/WordCloud.js index 63bcfbbe3dac..0851064262e3 100644 --- a/superset-frontend/src/explore/controlPanels/WordCloud.js +++ b/superset-frontend/src/explore/controlPanels/WordCloud.js @@ -17,7 +17,7 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import { nonEmpty } from '../validators'; +import { validateNonEmpty } from '@superset-ui/validator'; export default { controlPanelSections: [ @@ -83,7 +83,7 @@ export default { ], controlOverrides: { series: { - validators: [nonEmpty], + validators: [validateNonEmpty], clearable: false, }, row_limit: { diff --git a/superset-frontend/src/explore/controlPanels/WorldMap.js b/superset-frontend/src/explore/controlPanels/WorldMap.js index 3cf8dcf6f112..d4c0dfbae82c 100644 --- a/superset-frontend/src/explore/controlPanels/WorldMap.js +++ b/superset-frontend/src/explore/controlPanels/WorldMap.js @@ -17,6 +17,7 @@ * under the License. */ import { t } from '@superset-ui/translation'; +import { formatSelectOptions } from '../../modules/utils'; export default { controlPanelSections: [ @@ -66,7 +67,26 @@ export default { }, ], ['secondary_metric'], - ['max_bubble_size'], + [ + { + name: 'max_bubble_size', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Max Bubble Size'), + default: '25', + choices: formatSelectOptions([ + '5', + '10', + '15', + '25', + '50', + '75', + '100', + ]), + }, + }, + ], ], }, ], diff --git a/superset-frontend/src/explore/controlPanels/sections.jsx b/superset-frontend/src/explore/controlPanels/sections.jsx index ef63dcae7ed2..c361926f6a8f 100644 --- a/superset-frontend/src/explore/controlPanels/sections.jsx +++ b/superset-frontend/src/explore/controlPanels/sections.jsx @@ -18,6 +18,7 @@ */ import React from 'react'; import { t } from '@superset-ui/translation'; +import { formatSelectOptions } from '../../modules/utils'; export const druidTimeSeries = { label: t('Time'), @@ -78,10 +79,65 @@ export const NVD3TimeSeries = [ [

{t('Rolling Window')}

], ['rolling_type', 'rolling_periods', 'min_periods'], [

{t('Time Comparison')}

], - ['time_compare', 'comparison_type'], + [ + { + name: 'time_compare', + config: { + type: 'SelectControl', + multi: true, + freeForm: true, + label: t('Time Shift'), + choices: formatSelectOptions([ + '1 day', + '1 week', + '28 days', + '30 days', + '52 weeks', + '1 year', + ]), + description: t( + 'Overlay one or more timeseries from a ' + + 'relative time period. Expects relative time deltas ' + + 'in natural language (example: 24 hours, 7 days, ' + + '56 weeks, 365 days)', + ), + }, + }, + 'comparison_type', + ], [

{t('Python Functions')}

], [

pandas.resample

], - ['resample_rule', 'resample_method'], + [ + { + name: 'resample_rule', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Rule'), + default: null, + choices: formatSelectOptions(['1T', '1H', '1D', '7D', '1M', '1AS']), + description: t('Pandas resample rule'), + }, + }, + { + name: 'resample_method', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Method'), + default: null, + choices: formatSelectOptions([ + 'asfreq', + 'bfill', + 'ffill', + 'median', + 'mean', + 'sum', + ]), + description: t('Pandas resample method'), + }, + }, + ], ], }, ]; diff --git a/superset-frontend/src/explore/controlUtils.js b/superset-frontend/src/explore/controlUtils.js index 361e162c2e83..f4506a8412b8 100644 --- a/superset-frontend/src/explore/controlUtils.js +++ b/superset-frontend/src/explore/controlUtils.js @@ -17,8 +17,8 @@ * under the License. */ import { getChartControlPanelRegistry } from '@superset-ui/chart'; -import controls from './controls'; -import * as sections from './controlPanels/sections'; +import { controls as SHARED_CONTROLS } from './controls'; +import * as SECTIONS from './controlPanels/sections'; export function getFormDataFromControls(controlsState) { const formData = {}; @@ -45,38 +45,36 @@ export function validateControl(control) { return control; } -function isGlobalControl(controlKey) { - return controlKey in controls; +function findCustomControl(controlPanelSections, controlKey) { + // find custom control in `controlPanelSections` and apply `controlOverrides` if needed. + for (const section of controlPanelSections) { + for (const controlArr of section.controlSetRows) { + for (const control of controlArr) { + if (control != null && typeof control === 'object') { + if (control.config && control.name === controlKey) { + return control.config; + } + } + } + } + } + return null; } export function getControlConfig(controlKey, vizType) { - // Gets the control definition, applies overrides, and executes - // the mapStatetoProps const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {}; const { controlOverrides = {}, controlPanelSections = [], } = controlPanelConfig; - if (!isGlobalControl(controlKey)) { - for (const section of controlPanelSections) { - for (const controlArr of section.controlSetRows) { - for (const control of controlArr) { - if (control != null && typeof control === 'object') { - if (control.config && control.name === controlKey) { - return { - ...control.config, - ...controlOverrides[controlKey], - }; - } - } - } - } - } - } + const config = + controlKey in SHARED_CONTROLS + ? SHARED_CONTROLS[controlKey] + : findCustomControl(controlPanelSections, controlKey); return { - ...controls[controlKey], + ...config, ...controlOverrides[controlKey], }; } @@ -150,31 +148,25 @@ export function sectionsToRender(vizType, datasourceType) { controlPanelSections = [], } = controlPanelConfig; - const sectionsCopy = { ...sections }; + const sections = { ...SECTIONS }; Object.entries(sectionOverrides).forEach(([section, overrides]) => { if (typeof overrides === 'object' && overrides.constructor === Object) { - sectionsCopy[section] = { - ...sectionsCopy[section], + sections[section] = { + ...sections[section], ...overrides, }; } else { - sectionsCopy[section] = overrides; + sections[section] = overrides; } }); - const { - datasourceAndVizType, - sqlaTimeSeries, - druidTimeSeries, - } = sectionsCopy; + const { datasourceAndVizType, sqlaTimeSeries, druidTimeSeries } = sections; + const timeSection = + datasourceType === 'table' ? sqlaTimeSeries : druidTimeSeries; return [] - .concat( - datasourceAndVizType, - datasourceType === 'table' ? sqlaTimeSeries : druidTimeSeries, - controlPanelSections, - ) + .concat(datasourceAndVizType, timeSection, controlPanelSections) .filter(section => section); } diff --git a/superset-frontend/src/explore/controls.jsx b/superset-frontend/src/explore/controls.jsx index ce1fdcf23dd7..055410a7f051 100644 --- a/superset-frontend/src/explore/controls.jsx +++ b/superset-frontend/src/explore/controls.jsx @@ -62,28 +62,29 @@ import { getCategoricalSchemeRegistry, getSequentialSchemeRegistry, } from '@superset-ui/color'; +import { + legacyValidateInteger, + validateNonEmpty, +} from '@superset-ui/validator'; import { formatSelectOptionsForRange, formatSelectOptions, mainMetric, } from '../modules/utils'; -import * as v from './validators'; import ColumnOption from '../components/ColumnOption'; -import OptionDescription from '../components/OptionDescription'; -import { DEFAULT_VIEWPORT } from '../explore/components/controls/ViewportControl'; import { TIME_FILTER_LABELS } from './constants'; const categoricalSchemeRegistry = getCategoricalSchemeRegistry(); const sequentialSchemeRegistry = getSequentialSchemeRegistry(); -const PRIMARY_COLOR = { r: 0, g: 122, b: 135, a: 1 }; - -const D3_FORMAT_DOCS = 'D3 format syntax: https://github.com/d3/d3-format'; +export const PRIMARY_COLOR = { r: 0, g: 122, b: 135, a: 1 }; // input choices & options -const D3_FORMAT_OPTIONS = [ +export const D3_FORMAT_OPTIONS = [ ['SMART_NUMBER', 'Adaptative formating'], + [' ', 'Original value'], + [',d', ',d (12345.432 => 12,345)'], ['.1s', '.1s (12345.432 => 10k)'], ['.3s', '.3s (12345.432 => 12.3k)'], [',.1%', ',.1% (12345.432 => 1,234,543.2%)'], @@ -100,6 +101,9 @@ const ROW_LIMIT_OPTIONS = [10, 50, 100, 250, 500, 1000, 5000, 10000, 50000]; const SERIES_LIMITS = [0, 5, 10, 25, 50, 100, 500]; +export const D3_FORMAT_DOCS = + 'D3 format syntax: https://github.com/d3/d3-format'; + export const D3_TIME_FORMAT_OPTIONS = [ ['smart_date', 'Adaptative formating'], ['%d/%m/%Y', '%d/%m/%Y | 14/01/2019'], @@ -118,12 +122,6 @@ const timeColumnOption = { 'account', ), }; -const sortAxisChoices = [ - ['alpha_asc', 'Axis ascending'], - ['alpha_desc', 'Axis descending'], - ['value_asc', 'sum(value) ascending'], - ['value_desc', 'sum(value) descending'], -]; const groupByControl = { type: 'SelectControl', @@ -160,7 +158,7 @@ const metrics = { type: 'MetricsControl', multi: true, label: t('Metrics'), - validators: [v.nonEmpty], + validators: [validateNonEmpty], default: c => { const metric = mainMetric(c.savedMetrics); return metric ? [metric] : null; @@ -183,20 +181,7 @@ const metric = { default: props => mainMetric(props.savedMetrics), }; -const sandboxUrl = - 'https://github.com/apache/incubator-superset/' + - 'blob/master/superset-frontend/src/modules/sandbox.js'; -const jsFunctionInfo = ( -
- {t( - 'For more information about objects are in context in the scope of this function, refer to the', - )} - {t(" source code of Superset's sandboxed parser")}. - . -
-); - -function columnChoices(datasource) { +export function columnChoices(datasource) { if (datasource && datasource.columns) { return datasource.columns .map(col => [col.column_name, col.verbose_name || col.column_name]) @@ -207,38 +192,6 @@ function columnChoices(datasource) { return []; } -function jsFunctionControl( - label, - description, - extraDescr = null, - height = 100, - defaultText = '', -) { - return { - type: 'TextAreaControl', - language: 'javascript', - label, - description, - height, - default: defaultText, - aboveEditorSection: ( -
-

{description}

-

{jsFunctionInfo}

- {extraDescr} -
- ), - mapStateToProps: state => ({ - warning: !state.common.conf.ENABLE_JAVASCRIPT_CONTROLS - ? t( - 'This functionality is disabled in your environment for security reasons.', - ) - : null, - readOnly: !state.common.conf.ENABLE_JAVASCRIPT_CONTROLS, - }), - }; -} - export const controls = { metrics, @@ -262,38 +215,6 @@ export const controls = { description: t('The type of visualization to display'), }, - percent_metrics: { - ...metrics, - multi: true, - default: [], - label: t('Percentage Metrics'), - validators: [], - description: t('Metrics for which percentage of total are to be displayed'), - }, - - y_axis_bounds: { - type: 'BoundsControl', - label: t('Y Axis Bounds'), - renderTrigger: true, - default: [null, null], - description: t( - 'Bounds for the Y-axis. When left empty, the bounds are ' + - 'dynamically defined based on the min/max of the data. Note that ' + - "this feature will only expand the axis range. It won't " + - "narrow the data's extent.", - ), - }, - - order_by_cols: { - type: 'SelectControl', - multi: true, - label: t('Ordering'), - default: [], - description: t('One or many metrics to display'), - mapStateToProps: state => ({ - choices: state.datasource ? state.datasource.order_by_choices : [], - }), - }, color_picker: { label: t('Fixed Color'), description: t('Use this to define a static color for all circles'), @@ -302,60 +223,6 @@ export const controls = { renderTrigger: true, }, - target_color_picker: { - label: t('Target Color'), - description: t('Color of the target location'), - type: 'ColorPickerControl', - default: PRIMARY_COLOR, - renderTrigger: true, - }, - - legend_position: { - label: t('Legend Position'), - description: t('Choose the position of the legend'), - type: 'SelectControl', - clearable: false, - default: 'tr', - choices: [ - [null, 'None'], - ['tl', 'Top left'], - ['tr', 'Top right'], - ['bl', 'Bottom left'], - ['br', 'Bottom right'], - ], - renderTrigger: true, - }, - - legend_format: { - label: t('Legend Format'), - description: t('Choose the format for legend values'), - type: 'SelectControl', - clearable: false, - default: D3_FORMAT_OPTIONS[0], - choices: D3_FORMAT_OPTIONS, - renderTrigger: true, - }, - - fill_color_picker: { - label: t('Fill Color'), - description: t( - ' Set the opacity to 0 if you do not want to override the color specified in the GeoJSON', - ), - type: 'ColorPickerControl', - default: PRIMARY_COLOR, - renderTrigger: true, - }, - - stroke_color_picker: { - label: t('Stroke Color'), - description: t( - ' Set the opacity to 0 if you do not want to override the color specified in the GeoJSON', - ), - type: 'ColorPickerControl', - default: PRIMARY_COLOR, - renderTrigger: true, - }, - metric_2: { ...metric, label: t('Right Axis Metric'), @@ -363,35 +230,6 @@ export const controls = { description: t('Choose a metric for right axis'), }, - stacked_style: { - type: 'SelectControl', - label: t('Stacked Style'), - renderTrigger: true, - choices: [ - ['stack', 'stack'], - ['stream', 'stream'], - ['expand', 'expand'], - ], - default: 'stack', - description: '', - }, - - sort_x_axis: { - type: 'SelectControl', - label: t('Sort X Axis'), - choices: sortAxisChoices, - clearable: false, - default: 'alpha_asc', - }, - - sort_y_axis: { - type: 'SelectControl', - label: t('Sort Y Axis'), - choices: sortAxisChoices, - clearable: false, - default: 'alpha_asc', - }, - linear_color_scheme: { type: 'ColorSchemeControl', label: t('Linear Color Scheme'), @@ -421,178 +259,6 @@ export const controls = { ), }, - horizon_color_scale: { - type: 'SelectControl', - renderTrigger: true, - label: t('Value Domain'), - choices: [ - ['series', 'series'], - ['overall', 'overall'], - ['change', 'change'], - ], - default: 'series', - description: t( - 'series: Treat each series independently; overall: All series use the same scale; change: Show changes compared to the first data point in each series', - ), - }, - - canvas_image_rendering: { - type: 'SelectControl', - label: t('Rendering'), - renderTrigger: true, - choices: [ - ['pixelated', 'pixelated (Sharp)'], - ['auto', 'auto (Smooth)'], - ], - default: 'pixelated', - description: t( - 'image-rendering CSS attribute of the canvas object that ' + - 'defines how the browser scales up the image', - ), - }, - - xscale_interval: { - type: 'SelectControl', - label: t('XScale Interval'), - renderTrigger: true, - choices: formatSelectOptionsForRange(1, 50), - default: '1', - clearable: false, - description: t( - 'Number of steps to take between ticks when displaying the X scale', - ), - }, - - yscale_interval: { - type: 'SelectControl', - label: t('YScale Interval'), - choices: formatSelectOptionsForRange(1, 50), - default: '1', - clearable: false, - renderTrigger: true, - description: t( - 'Number of steps to take between ticks when displaying the Y scale', - ), - }, - - include_time: { - type: 'CheckboxControl', - label: t('Include Time'), - description: t( - 'Whether to include the time granularity as defined in the time section', - ), - default: false, - }, - - autozoom: { - type: 'CheckboxControl', - label: t('Auto Zoom'), - default: true, - renderTrigger: true, - description: t( - 'When checked, the map will zoom to your data after each query', - ), - }, - - show_perc: { - type: 'CheckboxControl', - label: t('Show percentage'), - renderTrigger: true, - description: t('Whether to include the percentage in the tooltip'), - default: true, - }, - - bar_stacked: { - type: 'CheckboxControl', - label: t('Stacked Bars'), - renderTrigger: true, - default: false, - description: null, - }, - - pivot_margins: { - type: 'CheckboxControl', - label: t('Show totals'), - renderTrigger: false, - default: true, - description: t('Display total row/column'), - }, - - transpose_pivot: { - type: 'CheckboxControl', - label: t('Transpose Pivot'), - default: false, - description: t('Swap Groups and Columns'), - }, - - show_markers: { - type: 'CheckboxControl', - label: t('Show Markers'), - renderTrigger: true, - default: false, - description: t('Show data points as circle markers on the lines'), - }, - - show_bar_value: { - type: 'CheckboxControl', - label: t('Bar Values'), - default: false, - renderTrigger: true, - description: t('Show the value on top of the bar'), - }, - - order_bars: { - type: 'CheckboxControl', - label: t('Sort Bars'), - default: false, - renderTrigger: true, - description: t('Sort bars by x labels.'), - }, - - combine_metric: { - type: 'CheckboxControl', - label: t('Combine Metrics'), - default: false, - description: t( - 'Display metrics side by side within each column, as ' + - 'opposed to each column being displayed side by side for each metric.', - ), - }, - - show_controls: { - type: 'CheckboxControl', - label: t('Extra Controls'), - renderTrigger: true, - default: false, - description: t( - 'Whether to show extra controls or not. Extra controls ' + - 'include things like making mulitBar charts stacked ' + - 'or side by side.', - ), - }, - - reduce_x_ticks: { - type: 'CheckboxControl', - label: t('Reduce X ticks'), - renderTrigger: true, - default: false, - description: t( - 'Reduces the number of X-axis ticks to be rendered. ' + - 'If true, the x-axis will not overflow and labels may be ' + - 'missing. If false, a minimum width will be applied ' + - 'to columns and the width may overflow into an ' + - 'horizontal scroll.', - ), - }, - - include_series: { - type: 'CheckboxControl', - label: t('Include Series'), - renderTrigger: true, - default: false, - description: t('Include series name as an axis'), - }, - secondary_metric: { ...metric, label: t('Color Metric'), @@ -636,45 +302,13 @@ export const controls = { description: t('The name of the country that Superset should display'), }, - freq: { - type: 'SelectControl', - label: t('Frequency'), - default: 'W-MON', - freeForm: true, - clearable: false, - choices: [ - ['AS', 'Year (freq=AS)'], - ['52W-MON', '52 weeks starting Monday (freq=52W-MON)'], - ['W-SUN', '1 week starting Sunday (freq=W-SUN)'], - ['W-MON', '1 week starting Monday (freq=W-MON)'], - ['D', 'Day (freq=D)'], - ['4W-MON', '4 weeks (freq=4W-MON)'], - ], - description: t( - `The periodicity over which to pivot time. Users can provide - "Pandas" offset alias. - Click on the info bubble for more details on accepted "freq" expressions.`, - ), - tooltipOnClick: () => { - window.open( - 'https://pandas.pydata.org/pandas-docs/stable/timeseries.html#offset-aliases', - ); - }, - }, - groupby: groupByControl, - dimension: { - ...groupByControl, - label: t('Dimension'), - description: t('Select a dimension'), - multi: false, - default: null, - }, - columns: Object.assign({}, groupByControl, { + columns: { + ...groupByControl, label: t('Columns'), description: t('One or many controls to pivot as columns'), - }), + }, all_columns: { type: 'SelectControl', @@ -693,41 +327,11 @@ export const controls = { freeForm: true, }, - spatial: { - type: 'SpatialControl', - label: t('Longitude & Latitude'), - validators: [v.nonEmpty], - description: t('Point to your spatial columns'), - mapStateToProps: state => ({ - choices: columnChoices(state.datasource), - }), - }, - - start_spatial: { - type: 'SpatialControl', - label: t('Start Longitude & Latitude'), - validators: [v.nonEmpty], - description: t('Point to your spatial columns'), - mapStateToProps: state => ({ - choices: columnChoices(state.datasource), - }), - }, - - end_spatial: { - type: 'SpatialControl', - label: t('End Longitude & Latitude'), - validators: [v.nonEmpty], - description: t('Point to your spatial columns'), - mapStateToProps: state => ({ - choices: columnChoices(state.datasource), - }), - }, - longitude: { type: 'SelectControl', label: t('Longitude'), default: 1, - validators: [v.nonEmpty], + validators: [validateNonEmpty], description: t('Select the longitude column'), mapStateToProps: state => ({ choices: columnChoices(state.datasource), @@ -738,34 +342,17 @@ export const controls = { type: 'SelectControl', label: t('Latitude'), default: 1, - validators: [v.nonEmpty], + validators: [validateNonEmpty], description: t('Select the latitude column'), mapStateToProps: state => ({ choices: columnChoices(state.datasource), }), }, - filter_nulls: { - type: 'CheckboxControl', - label: t('Ignore null locations'), - default: true, - description: t('Whether to ignore locations that are null'), - }, - - geojson: { - type: 'SelectControl', - label: t('GeoJson Column'), - validators: [v.nonEmpty], - description: t('Select the geojson column'), - mapStateToProps: state => ({ - choices: columnChoices(state.datasource), - }), - }, - polygon: { type: 'SelectControl', label: t('Polygon Column'), - validators: [v.nonEmpty], + validators: [validateNonEmpty], description: t( 'Select the polygon column. Each row should contain JSON.array(N) of [longitude, latitude] points', ), @@ -774,25 +361,6 @@ export const controls = { }), }, - point_radius_scale: { - type: 'SelectControl', - freeForm: true, - label: t('Point Radius Scale'), - validators: [v.integer], - default: null, - choices: formatSelectOptions([0, 100, 200, 300, 500]), - }, - - stroke_width: { - type: 'SelectControl', - freeForm: true, - label: t('Stroke Width'), - validators: [v.integer], - default: null, - renderTrigger: true, - choices: formatSelectOptions([1, 2, 3, 4, 5]), - }, - all_columns_x: { type: 'SelectControl', label: 'X', @@ -828,42 +396,6 @@ export const controls = { ), }, - bottom_margin: { - type: 'SelectControl', - clearable: false, - freeForm: true, - label: t('Bottom Margin'), - choices: formatSelectOptions(['auto', 50, 75, 100, 125, 150, 200]), - default: 'auto', - renderTrigger: true, - description: t( - 'Bottom margin, in pixels, allowing for more room for axis labels', - ), - }, - - x_ticks_layout: { - type: 'SelectControl', - label: t('X Tick Layout'), - choices: formatSelectOptions(['auto', 'flat', '45°', 'staggered']), - default: 'auto', - clearable: false, - renderTrigger: true, - description: t('The way the ticks are laid out on the X-axis'), - }, - - left_margin: { - type: 'SelectControl', - freeForm: true, - clearable: false, - label: t('Left Margin'), - choices: formatSelectOptions(['auto', 50, 75, 100, 125, 150, 200]), - default: 'auto', - renderTrigger: true, - description: t( - 'Left margin, in pixels, allowing for more room for axis labels', - ), - }, - granularity: { type: 'SelectControl', freeForm: true, @@ -894,26 +426,7 @@ export const controls = { ), }, - domain_granularity: { - type: 'SelectControl', - label: t('Domain'), - default: 'month', - choices: formatSelectOptions(['hour', 'day', 'week', 'month', 'year']), - description: t('The time unit used for the grouping of blocks'), - }, - - subdomain_granularity: { - type: 'SelectControl', - label: t('Subdomain'), - default: 'day', - choices: formatSelectOptions(['min', 'hour', 'day', 'week', 'month']), - description: t( - 'The time unit for each block. Should be a smaller unit than ' + - 'domain_granularity. Should be larger or equal to Time Grain', - ), - }, - - link_length: { + link_length: { type: 'SelectControl', renderTrigger: true, freeForm: true, @@ -932,27 +445,6 @@ export const controls = { description: t('Link length in the force layout'), }, - charge: { - type: 'SelectControl', - renderTrigger: true, - freeForm: true, - label: t('Charge'), - default: '-500', - choices: formatSelectOptions([ - '-50', - '-75', - '-100', - '-150', - '-200', - '-250', - '-500', - '-1000', - '-2500', - '-5000', - ]), - description: t('Charge in the force layout'), - }, - granularity_sqla: { type: 'SelectControl', label: TIME_FILTER_LABELS.granularity_sqla, @@ -999,36 +491,11 @@ export const controls = { }), }, - resample_rule: { - type: 'SelectControl', - freeForm: true, - label: t('Rule'), - default: null, - choices: formatSelectOptions(['1T', '1H', '1D', '7D', '1M', '1AS']), - description: t('Pandas resample rule'), - }, - - resample_method: { - type: 'SelectControl', - freeForm: true, - label: t('Method'), - default: null, - choices: formatSelectOptions([ - 'asfreq', - 'bfill', - 'ffill', - 'median', - 'mean', - 'sum', - ]), - description: t('Pandas resample method'), - }, - time_range: { type: 'DateFilterControl', freeForm: true, label: TIME_FILTER_LABELS.time_range, - default: t('Last week'), + default: t('Last week'), // this value is translated, but the backend wouldn't understand a translated value? description: t( 'The time range for the visualization. All relative times, e.g. "Last month", ' + '"Last 7 days", "now", etc. are evaluated on the server using the server\'s ' + @@ -1042,35 +509,22 @@ export const controls = { }), }, - max_bubble_size: { - type: 'SelectControl', - freeForm: true, - label: t('Max Bubble Size'), - default: '25', - choices: formatSelectOptions(['5', '10', '15', '25', '50', '75', '100']), - }, - - whisker_options: { - type: 'SelectControl', - freeForm: true, - label: t('Whisker/outlier options'), - default: 'Tukey', - description: t('Determines how whiskers and outliers are calculated.'), - choices: formatSelectOptions([ - 'Tukey', - 'Min/max (no outliers)', - '2/98 percentiles', - '9/91 percentiles', - ]), - }, - - treemap_ratio: { - type: 'TextControl', - label: t('Ratio'), + time_range_fixed: { + type: 'CheckboxControl', + label: t('Fix to selected Time Range'), + description: t( + 'Fix the trend line to the full time range specified in case filtered results do not include the start or end dates', + ), renderTrigger: true, - isFloat: true, - default: 0.5 * (1 + Math.sqrt(5)), // d3 default, golden ratio - description: t('Target aspect ratio for treemap tiles.'), + visibility(props) { + const { + time_range: timeRange, + viz_type: vizType, + show_trend_line: showTrendLine, + } = props.form_data; + // only display this option when a time range is selected + return timeRange && timeRange !== 'No filter'; + }, }, number_format: { @@ -1087,7 +541,7 @@ export const controls = { type: 'SelectControl', freeForm: true, label: t('Row limit'), - validators: [v.integer], + validators: [legacyValidateInteger], default: 10000, choices: formatSelectOptions(ROW_LIMIT_OPTIONS), }, @@ -1096,7 +550,7 @@ export const controls = { type: 'SelectControl', freeForm: true, label: t('Series limit'), - validators: [v.integer], + validators: [legacyValidateInteger], choices: formatSelectOptions(SERIES_LIMITS), description: t( 'Limits the number of time series that get displayed. A sub query ' + @@ -1136,15 +590,6 @@ export const controls = { ), }, - multiplier: { - type: 'TextControl', - label: t('Multiplier'), - isFloat: true, - renderTrigger: true, - default: 1, - description: t('Factor to multiply the metric by'), - }, - rolling_periods: { type: 'TextControl', label: t('Periods'), @@ -1155,55 +600,6 @@ export const controls = { ), }, - cell_size: { - type: 'TextControl', - isInt: true, - default: 10, - validators: [v.integer], - renderTrigger: true, - label: t('Cell Size'), - description: t('The size of the square cell, in pixels'), - }, - - cell_padding: { - type: 'TextControl', - isInt: true, - validators: [v.integer], - renderTrigger: true, - default: 2, - label: t('Cell Padding'), - description: t('The distance between cells, in pixels'), - }, - - cell_radius: { - type: 'TextControl', - isInt: true, - validators: [v.integer], - renderTrigger: true, - default: 0, - label: t('Cell Radius'), - description: t('The pixel radius'), - }, - - steps: { - type: 'TextControl', - isInt: true, - validators: [v.integer], - renderTrigger: true, - default: 10, - label: t('Color Steps'), - description: t('The number color "steps"'), - }, - - grid_size: { - type: 'TextControl', - label: t('Grid Size'), - renderTrigger: true, - default: 20, - isInt: true, - description: t('Defines the grid size in pixels'), - }, - min_periods: { type: 'TextControl', label: t('Min Periods'), @@ -1234,7 +630,7 @@ export const controls = { label: t('Entity'), default: null, multi: false, - validators: [v.nonEmpty], + validators: [validateNonEmpty], description: t('This defines the element to be plotted on the chart'), }, @@ -1268,96 +664,6 @@ export const controls = { default: '', }, - x_axis_label: { - type: 'TextControl', - label: t('X Axis Label'), - renderTrigger: true, - default: '', - }, - - y_axis_label: { - type: 'TextControl', - label: t('Y Axis Label'), - renderTrigger: true, - default: '', - }, - - compare_lag: { - type: 'TextControl', - label: t('Comparison Period Lag'), - isInt: true, - description: t( - 'Based on granularity, number of time periods to compare against', - ), - }, - - compare_suffix: { - type: 'TextControl', - label: t('Comparison suffix'), - description: t('Suffix to apply after the percentage display'), - }, - - table_timestamp_format: { - type: 'SelectControl', - freeForm: true, - label: t('Table Timestamp Format'), - default: '%Y-%m-%d %H:%M:%S', - renderTrigger: true, - validators: [v.nonEmpty], - clearable: false, - choices: D3_TIME_FORMAT_OPTIONS, - description: t('Timestamp Format'), - }, - - series_height: { - type: 'SelectControl', - renderTrigger: true, - freeForm: true, - label: t('Series Height'), - default: '25', - choices: formatSelectOptions([ - '10', - '25', - '40', - '50', - '75', - '100', - '150', - '200', - ]), - description: t('Pixel height of each series'), - }, - - page_length: { - type: 'SelectControl', - freeForm: true, - renderTrigger: true, - label: t('Page Length'), - default: 0, - choices: formatSelectOptions([0, 10, 25, 40, 50, 75, 100, 150, 200]), - description: t('Rows per page, 0 means no pagination'), - }, - - x_axis_format: { - type: 'SelectControl', - freeForm: true, - label: t('X Axis Format'), - renderTrigger: true, - default: 'SMART_NUMBER', - choices: D3_FORMAT_OPTIONS, - description: D3_FORMAT_DOCS, - }, - - x_axis_time_format: { - type: 'SelectControl', - freeForm: true, - label: t('X Axis Format'), - renderTrigger: true, - default: 'smart_date', - choices: D3_TIME_FORMAT_OPTIONS, - description: D3_FORMAT_DOCS, - }, - y_axis_format: { type: 'SelectControl', freeForm: true, @@ -1383,15 +689,6 @@ export const controls = { }, }, - y_axis_2_format: { - type: 'SelectControl', - freeForm: true, - label: t('Right Axis Format'), - default: 'SMART_NUMBER', - choices: D3_FORMAT_OPTIONS, - description: D3_FORMAT_DOCS, - }, - date_time_format: { type: 'SelectControl', freeForm: true, @@ -1408,41 +705,10 @@ export const controls = { clearable: false, choices: formatSelectOptions(['markdown', 'html']), default: 'markdown', - validators: [v.nonEmpty], + validators: [validateNonEmpty], description: t('Pick your favorite markup language'), }, - line_interpolation: { - type: 'SelectControl', - label: t('Line Style'), - renderTrigger: true, - choices: formatSelectOptions([ - 'linear', - 'basis', - 'cardinal', - 'monotone', - 'step-before', - 'step-after', - ]), - default: 'linear', - description: t('Line interpolation as defined by d3.js'), - }, - - pie_label_type: { - type: 'SelectControl', - label: t('Label Type'), - default: 'key', - renderTrigger: true, - choices: [ - ['key', 'Category Name'], - ['value', 'Value'], - ['percent', 'Percentage'], - ['key_value', 'Category and Value'], - ['key_percent', 'Category and Percentage'], - ], - description: t('What should be shown on the label?'), - }, - code: { type: 'TextAreaControl', label: t('Code'), @@ -1468,174 +734,6 @@ export const controls = { ), }, - js_agg_function: { - type: 'SelectControl', - label: t('Dynamic Aggregation Function'), - description: t('The function to use when aggregating points into groups'), - default: 'sum', - clearable: false, - renderTrigger: true, - choices: formatSelectOptions([ - 'sum', - 'min', - 'max', - 'mean', - 'median', - 'count', - 'variance', - 'deviation', - 'p1', - 'p5', - 'p95', - 'p99', - ]), - }, - - header_font_size: { - type: 'SelectControl', - label: t('Header Font Size'), - renderTrigger: true, - clearable: false, - default: 0.3, - // Values represent the percentage of space a header should take - options: [ - { - label: t('Tiny'), - value: 0.125, - }, - { - label: t('Small'), - value: 0.2, - }, - { - label: t('Normal'), - value: 0.3, - }, - { - label: t('Large'), - value: 0.4, - }, - { - label: t('Huge'), - value: 0.5, - }, - ], - }, - - subheader_font_size: { - type: 'SelectControl', - label: t('Subheader Font Size'), - renderTrigger: true, - clearable: false, - default: 0.125, - // Values represent the percentage of space a subheader should take - options: [ - { - label: t('Tiny'), - value: 0.125, - }, - { - label: t('Small'), - value: 0.2, - }, - { - label: t('Normal'), - value: 0.3, - }, - { - label: t('Large'), - value: 0.4, - }, - { - label: t('Huge'), - value: 0.5, - }, - ], - }, - - instant_filtering: { - type: 'CheckboxControl', - label: t('Instant Filtering'), - renderTrigger: true, - default: true, - description: - 'Whether to apply filters as they change, or wait for ' + - 'users to hit an [Apply] button', - }, - - extruded: { - type: 'CheckboxControl', - label: t('Extruded'), - renderTrigger: true, - default: true, - description: 'Whether to make the grid 3D', - }, - - show_brush: { - type: 'SelectControl', - label: t('Show Range Filter'), - renderTrigger: true, - clearable: false, - default: 'auto', - choices: [ - ['yes', 'Yes'], - ['no', 'No'], - ['auto', 'Auto'], - ], - description: t('Whether to display the time range interactive selector'), - }, - - date_filter: { - type: 'CheckboxControl', - label: t('Date Filter'), - default: true, - description: t('Whether to include a time filter'), - }, - - show_sqla_time_granularity: { - type: 'CheckboxControl', - label: t('Show SQL Granularity Dropdown'), - default: false, - description: t('Check to include SQL Granularity dropdown'), - }, - - show_sqla_time_column: { - type: 'CheckboxControl', - label: t('Show SQL Time Column'), - default: false, - description: t('Check to include Time Column dropdown'), - }, - - show_druid_time_granularity: { - type: 'CheckboxControl', - label: t('Show Druid Granularity Dropdown'), - default: false, - description: t('Check to include Druid Granularity dropdown'), - }, - - show_druid_time_origin: { - type: 'CheckboxControl', - label: t('Show Druid Time Origin'), - default: false, - description: t('Check to include Time Origin dropdown'), - }, - - show_datatable: { - type: 'CheckboxControl', - label: t('Data Table'), - default: false, - renderTrigger: true, - description: t('Whether to display the interactive data table'), - }, - - include_search: { - type: 'CheckboxControl', - label: t('Search Box'), - renderTrigger: true, - default: false, - description: t('Whether to include a client-side search box'), - }, - table_filter: { type: 'CheckboxControl', label: t('Emit Filter Events'), @@ -1644,230 +742,47 @@ export const controls = { description: t('Whether to apply filter when items are clicked'), }, - align_pn: { + show_values: { type: 'CheckboxControl', - label: t('Align +/-'), + label: t('Show Values'), renderTrigger: true, default: false, - description: t('Whether to align the background chart for +/- values'), - }, - - color_pn: { - type: 'CheckboxControl', - label: t('Color +/-'), - renderTrigger: true, - default: true, - description: t('Whether to color +/- values'), + description: t('Whether to display the numerical values within the cells'), }, - show_legend: { + log_scale: { type: 'CheckboxControl', - label: t('Legend'), + label: t('Log Scale'), + default: false, renderTrigger: true, - default: true, - description: t('Whether to display the legend (toggles)'), + description: t('Use a log scale'), }, - send_time_range: { + contribution: { type: 'CheckboxControl', - label: t('Propagate'), - renderTrigger: true, + label: t('Contribution'), default: false, - description: t('Send range filter events to other charts'), - }, - - toggle_polygons: { - type: 'CheckboxControl', - label: t('Multiple filtering'), - renderTrigger: true, - default: true, - description: t('Allow sending multiple polygons as a filter event'), + description: t('Compute the contribution to the total'), }, - num_buckets: { + comparison_type: { type: 'SelectControl', - multi: false, - freeForm: true, - label: t('Number of buckets to group data'), - default: 5, - choices: formatSelectOptions([2, 3, 5, 10]), - description: t('How many buckets should the data be grouped in.'), - renderTrigger: true, + label: t('Calculation type'), + default: 'values', + choices: [ + ['values', 'Actual Values'], + ['absolute', 'Absolute difference'], + ['percentage', 'Percentage change'], + ['ratio', 'Ratio'], + ], + description: t( + 'How to display time shifts: as individual lines; as the ' + + 'absolute difference between the main time series and each time shift; ' + + 'as the percentage change; or as the ratio between series and time shifts.', + ), }, - break_points: { - type: 'SelectControl', - multi: true, - freeForm: true, - label: t('Bucket break points'), - choices: formatSelectOptions([]), - description: t('List of n+1 values for bucketing metric into n buckets.'), - renderTrigger: true, - }, - - show_labels: { - type: 'CheckboxControl', - label: t('Show Labels'), - renderTrigger: true, - default: true, - description: t( - 'Whether to display the labels. Note that the label only displays when the the 5% ' + - 'threshold.', - ), - }, - - show_values: { - type: 'CheckboxControl', - label: t('Show Values'), - renderTrigger: true, - default: false, - description: t('Whether to display the numerical values within the cells'), - }, - - show_metric_name: { - type: 'CheckboxControl', - label: t('Show Metric Names'), - renderTrigger: true, - default: true, - description: t('Whether to display the metric name as a title'), - }, - - show_trend_line: { - type: 'CheckboxControl', - label: t('Show Trend Line'), - renderTrigger: true, - default: true, - description: t('Whether to display the trend line'), - }, - - start_y_axis_at_zero: { - type: 'CheckboxControl', - label: t('Start y-axis at 0'), - renderTrigger: true, - default: true, - description: t( - 'Start y-axis at zero. Uncheck to start y-axis at minimum value in the data.', - ), - }, - - x_axis_showminmax: { - type: 'CheckboxControl', - label: t('X bounds'), - renderTrigger: true, - default: false, - description: t('Whether to display the min and max values of the X-axis'), - }, - - y_axis_showminmax: { - type: 'CheckboxControl', - label: t('Y bounds'), - renderTrigger: true, - default: false, - description: t('Whether to display the min and max values of the Y-axis'), - }, - - rich_tooltip: { - type: 'CheckboxControl', - label: t('Rich Tooltip'), - renderTrigger: true, - default: true, - description: t( - 'The rich tooltip shows a list of all series for that point in time', - ), - }, - - y_log_scale: { - type: 'CheckboxControl', - label: t('Y Log Scale'), - default: false, - renderTrigger: true, - description: t('Use a log scale for the Y-axis'), - }, - - x_log_scale: { - type: 'CheckboxControl', - label: t('X Log Scale'), - default: false, - renderTrigger: true, - description: t('Use a log scale for the X-axis'), - }, - - log_scale: { - type: 'CheckboxControl', - label: t('Log Scale'), - default: false, - renderTrigger: true, - description: t('Use a log scale'), - }, - - donut: { - type: 'CheckboxControl', - label: t('Donut'), - default: false, - renderTrigger: true, - description: t('Do you want a donut or a pie?'), - }, - - labels_outside: { - type: 'CheckboxControl', - label: t('Put labels outside'), - default: true, - renderTrigger: true, - description: t('Put the labels outside the pie?'), - }, - - contribution: { - type: 'CheckboxControl', - label: t('Contribution'), - default: false, - description: t('Compute the contribution to the total'), - }, - - time_compare: { - type: 'SelectControl', - multi: true, - freeForm: true, - label: t('Time Shift'), - choices: formatSelectOptions([ - '1 day', - '1 week', - '28 days', - '30 days', - '52 weeks', - '1 year', - ]), - description: t( - 'Overlay one or more timeseries from a ' + - 'relative time period. Expects relative time deltas ' + - 'in natural language (example: 24 hours, 7 days, ' + - '56 weeks, 365 days)', - ), - }, - - comparison_type: { - type: 'SelectControl', - label: t('Calculation type'), - default: 'values', - choices: [ - ['values', 'Actual Values'], - ['absolute', 'Absolute difference'], - ['percentage', 'Percentage change'], - ['ratio', 'Ratio'], - ], - description: t( - 'How to display time shifts: as individual lines; as the ' + - 'absolute difference between the main time series and each time shift; ' + - 'as the percentage change; or as the ratio between series and time shifts.', - ), - }, - - subheader: { - type: 'TextControl', - label: t('Subheader'), - description: t('Description text that shows up below your Big Number'), - }, - - mapbox_label: { + mapbox_label: { type: 'SelectControl', multi: true, label: t('label'), @@ -1900,79 +815,6 @@ export const controls = { description: t('Base layer map style'), }, - clustering_radius: { - type: 'SelectControl', - freeForm: true, - label: t('Clustering Radius'), - default: '60', - choices: formatSelectOptions([ - '0', - '20', - '40', - '60', - '80', - '100', - '200', - '500', - '1000', - ]), - description: t( - 'The radius (in pixels) the algorithm uses to define a cluster. ' + - 'Choose 0 to turn off clustering, but beware that a large ' + - 'number of points (>1000) will cause lag.', - ), - }, - - point_radius_fixed: { - type: 'FixedOrMetricControl', - label: t('Point Size'), - default: { type: 'fix', value: 1000 }, - description: t('Fixed point radius'), - mapStateToProps: state => ({ - datasource: state.datasource, - }), - }, - - point_radius: { - type: 'SelectControl', - label: t('Point Radius'), - default: 'Auto', - description: t( - 'The radius of individual points (ones that are not in a cluster). ' + - 'Either a numerical column or `Auto`, which scales the point based ' + - 'on the largest cluster', - ), - mapStateToProps: state => ({ - choices: formatSelectOptions(['Auto']).concat( - columnChoices(state.datasource), - ), - }), - }, - - point_radius_unit: { - type: 'SelectControl', - label: t('Point Radius Unit'), - default: 'Pixels', - choices: formatSelectOptions(['Pixels', 'Miles', 'Kilometers']), - description: t('The unit of measure for the specified point radius'), - }, - - point_unit: { - type: 'SelectControl', - label: t('Point Unit'), - default: 'square_m', - clearable: false, - choices: [ - ['square_m', 'Square meters'], - ['square_km', 'Square kilometers'], - ['square_miles', 'Square miles'], - ['radius_m', 'Radius in meters'], - ['radius_km', 'Radius in kilometers'], - ['radius_miles', 'Radius in miles'], - ], - description: t('The unit of measure for the specified point radius'), - }, - global_opacity: { type: 'TextControl', label: t('Opacity'), @@ -1983,28 +825,6 @@ export const controls = { ), }, - opacity: { - type: 'SliderControl', - label: t('Opacity'), - default: 80, - step: 1, - min: 0, - max: 100, - renderTrigger: true, - description: t('Opacity, expects values between 0 and 100'), - }, - - viewport: { - type: 'ViewportControl', - label: t('Viewport'), - renderTrigger: false, - description: t('Parameters related to the view and perspective on the map'), - // default is whole world mostly centered - default: DEFAULT_VIEWPORT, - // Viewport changes shouldn't prompt user to re-run query - dontRefreshOnChange: true, - }, - viewport_zoom: { type: 'TextControl', label: t('Zoom'), @@ -2017,55 +837,6 @@ export const controls = { dontRefreshOnChange: true, }, - viewport_latitude: { - type: 'TextControl', - label: t('Default latitude'), - renderTrigger: true, - default: 37.772123, - isFloat: true, - description: t('Latitude of default viewport'), - places: 8, - // Viewport latitude changes shouldn't prompt user to re-run query - dontRefreshOnChange: true, - }, - - viewport_longitude: { - type: 'TextControl', - label: t('Default longitude'), - renderTrigger: true, - default: -122.405293, - isFloat: true, - description: t('Longitude of default viewport'), - places: 8, - // Viewport longitude changes shouldn't prompt user to re-run query - dontRefreshOnChange: true, - }, - - render_while_dragging: { - type: 'CheckboxControl', - label: t('Live render'), - default: true, - description: t( - 'Points and clusters will update as the viewport is being changed', - ), - }, - - mapbox_color: { - type: 'SelectControl', - freeForm: true, - label: t('RGB Color'), - default: 'rgb(0, 122, 135)', - choices: [ - ['rgb(0, 139, 139)', 'Dark Cyan'], - ['rgb(128, 0, 128)', 'Purple'], - ['rgb(255, 215, 0)', 'Gold'], - ['rgb(69, 69, 69)', 'Dim Gray'], - ['rgb(220, 20, 60)', 'Crimson'], - ['rgb(34, 139, 34)', 'Forest Green'], - ], - description: t('The color for points and clusters in RGB'), - }, - color: { type: 'ColorPickerControl', label: t('Color'), @@ -2073,48 +844,6 @@ export const controls = { description: t('Pick a color'), }, - ranges: { - type: 'TextControl', - label: t('Ranges'), - default: '', - description: t('Ranges to highlight with shading'), - }, - - range_labels: { - type: 'TextControl', - label: t('Range labels'), - default: '', - description: t('Labels for the ranges'), - }, - - markers: { - type: 'TextControl', - label: t('Markers'), - default: '', - description: t('List of values to mark with triangles'), - }, - - marker_labels: { - type: 'TextControl', - label: t('Marker labels'), - default: '', - description: t('Labels for the markers'), - }, - - marker_lines: { - type: 'TextControl', - label: t('Marker lines'), - default: '', - description: t('List of values to mark with lines'), - }, - - marker_line_labels: { - type: 'TextControl', - label: t('Marker line labels'), - default: '', - description: t('Labels for the marker lines'), - }, - annotation_layers: { type: 'AnnotationLayerControl', label: '', @@ -2167,28 +896,6 @@ export const controls = { description: t('Time range endpoints (SIP-15)'), }, - order_by_entity: { - type: 'CheckboxControl', - label: t('Order by entity id'), - description: t( - 'Important! Select this if the table is not already sorted by entity id, ' + - 'else there is no guarantee that all events for each entity are returned.', - ), - default: true, - }, - - min_leaf_node_event_count: { - type: 'SelectControl', - freeForm: false, - label: t('Minimum leaf node event count'), - default: 1, - choices: formatSelectOptionsForRange(1, 10), - description: t( - 'Leaf nodes that represent fewer than this number of events will be initially ' + - 'hidden in the visualization', - ), - }, - color_scheme: { type: 'ColorSchemeControl', label: t('Color Scheme'), @@ -2213,278 +920,10 @@ export const controls = { column_collection: { type: 'CollectionControl', label: t('Time Series Columns'), - validators: [v.nonEmpty], + validators: [validateNonEmpty], controlName: 'TimeSeriesColumnControl', }, - time_series_option: { - type: 'SelectControl', - label: t('Options'), - validators: [v.nonEmpty], - default: 'not_time', - valueKey: 'value', - options: [ - { - label: t('Not Time Series'), - value: 'not_time', - description: t('Ignore time'), - }, - { - label: t('Time Series'), - value: 'time_series', - description: t('Standard time series'), - }, - { - label: t('Aggregate Mean'), - value: 'agg_mean', - description: t('Mean of values over specified period'), - }, - { - label: t('Aggregate Sum'), - value: 'agg_sum', - description: t('Sum of values over specified period'), - }, - { - label: t('Difference'), - value: 'point_diff', - description: t('Metric change in value from `since` to `until`'), - }, - { - label: t('Percent Change'), - value: 'point_percent', - description: t( - 'Metric percent change in value from `since` to `until`', - ), - }, - { - label: t('Factor'), - value: 'point_factor', - description: t('Metric factor change from `since` to `until`'), - }, - { - label: t('Advanced Analytics'), - value: 'adv_anal', - description: t('Use the Advanced Analytics options below'), - }, - ], - optionRenderer: op => , - valueRenderer: op => , - description: t('Settings for time series'), - }, - - equal_date_size: { - type: 'CheckboxControl', - label: t('Equal Date Sizes'), - default: true, - renderTrigger: true, - description: t('Check to force date partitions to have the same height'), - }, - - partition_limit: { - type: 'TextControl', - label: t('Partition Limit'), - isInt: true, - default: '5', - description: t( - 'The maximum number of subdivisions of each group; ' + - 'lower values are pruned first', - ), - }, - - min_radius: { - type: 'TextControl', - label: t('Minimum Radius'), - isFloat: true, - validators: [v.nonEmpty], - renderTrigger: true, - default: 2, - description: t( - 'Minimum radius size of the circle, in pixels. As the zoom level changes, this ' + - 'insures that the circle respects this minimum radius.', - ), - }, - - max_radius: { - type: 'TextControl', - label: t('Maximum Radius'), - isFloat: true, - validators: [v.nonEmpty], - renderTrigger: true, - default: 250, - description: t( - 'Maxium radius size of the circle, in pixels. As the zoom level changes, this ' + - 'insures that the circle respects this maximum radius.', - ), - }, - - partition_threshold: { - type: 'TextControl', - label: t('Partition Threshold'), - isFloat: true, - default: '0.05', - description: t( - 'Partitions whose height to parent height proportions are ' + - 'below this value are pruned', - ), - }, - - line_column: { - type: 'SelectControl', - label: t('Lines column'), - default: null, - description: t('The database columns that contains lines information'), - mapStateToProps: state => ({ - choices: columnChoices(state.datasource), - }), - validators: [v.nonEmpty], - }, - line_type: { - type: 'SelectControl', - label: t('Lines encoding'), - clearable: false, - default: 'json', - description: t('The encoding format of the lines'), - choices: [ - ['polyline', 'Polyline'], - ['json', 'JSON'], - ['geohash', 'geohash (square)'], - ], - }, - - line_width: { - type: 'TextControl', - label: t('Line width'), - renderTrigger: true, - isInt: true, - default: 10, - description: t('The width of the lines'), - }, - - line_charts: { - type: 'SelectAsyncControl', - multi: true, - label: t('Line charts'), - validators: [v.nonEmpty], - default: [], - description: t('Pick a set of line charts to layer on top of one another'), - dataEndpoint: - '/sliceasync/api/read?_flt_0_viz_type=line&_flt_7_viz_type=line_multi', - placeholder: t('Select charts'), - onAsyncErrorMessage: t('Error while fetching charts'), - mutator: data => { - if (!data || !data.result) { - return []; - } - return data.result.map(o => ({ value: o.id, label: o.slice_name })); - }, - }, - - line_charts_2: { - type: 'SelectAsyncControl', - multi: true, - label: t('Right Axis chart(s)'), - validators: [], - default: [], - description: t('Choose one or more charts for right axis'), - dataEndpoint: - '/sliceasync/api/read?_flt_0_viz_type=line&_flt_7_viz_type=line_multi', - placeholder: t('Select charts'), - onAsyncErrorMessage: t('Error while fetching charts'), - mutator: data => { - if (!data || !data.result) { - return []; - } - return data.result.map(o => ({ value: o.id, label: o.slice_name })); - }, - }, - - prefix_metric_with_slice_name: { - type: 'CheckboxControl', - label: t('Prefix metric name with slice name'), - default: false, - renderTrigger: true, - }, - - reverse_long_lat: { - type: 'CheckboxControl', - label: t('Reverse Lat & Long'), - default: false, - }, - - deck_slices: { - type: 'SelectAsyncControl', - multi: true, - label: t('deck.gl charts'), - validators: [v.nonEmpty], - default: [], - description: t( - 'Pick a set of deck.gl charts to layer on top of one another', - ), - dataEndpoint: - '/sliceasync/api/read?_flt_0_viz_type=deck_&_flt_7_viz_type=deck_multi', - placeholder: t('Select charts'), - onAsyncErrorMessage: t('Error while fetching charts'), - mutator: data => { - if (!data || !data.result) { - return []; - } - return data.result.map(o => ({ value: o.id, label: o.slice_name })); - }, - }, - - js_data_mutator: jsFunctionControl( - t('Javascript data interceptor'), - t( - 'Define a javascript function that receives the data array used in the visualization ' + - 'and is expected to return a modified version of that array. This can be used ' + - 'to alter properties of the data, filter, or enrich the array.', - ), - ), - - js_data: jsFunctionControl( - t('Javascript data mutator'), - t( - 'Define a function that receives intercepts the data objects and can mutate it', - ), - ), - - js_tooltip: jsFunctionControl( - t('Javascript tooltip generator'), - t( - 'Define a function that receives the input and outputs the content for a tooltip', - ), - ), - - js_onclick_href: jsFunctionControl( - t('Javascript onClick href'), - t('Define a function that returns a URL to navigate to when user clicks'), - ), - - js_columns: { - ...groupByControl, - label: t('Extra data for JS'), - default: [], - description: t( - 'List of extra columns made available in Javascript functions', - ), - }, - - stroked: { - type: 'CheckboxControl', - label: t('Stroked'), - renderTrigger: true, - description: t('Whether to display the stroke'), - default: false, - }, - - filled: { - type: 'CheckboxControl', - label: t('Filled'), - renderTrigger: true, - description: t('Whether to fill the objects'), - default: true, - }, - filter_configs: { type: 'CollectionControl', label: 'Filters', diff --git a/superset-frontend/src/explore/exploreUtils.js b/superset-frontend/src/explore/exploreUtils.js index 4b9abebf807e..226989680f67 100644 --- a/superset-frontend/src/explore/exploreUtils.js +++ b/superset-frontend/src/explore/exploreUtils.js @@ -190,29 +190,36 @@ export function getExploreUrlAndPayload({ }; } -export function exportChart(formData, endpointType) { - const { url, payload } = getExploreUrlAndPayload({ - formData, - endpointType, - allowDomainSharding: false, - }); +export function postForm(url, payload, target = '_blank') { + if (!url) { + return; + } - const exploreForm = document.createElement('form'); - exploreForm.action = url; - exploreForm.method = 'POST'; - exploreForm.target = '_blank'; + const hiddenForm = document.createElement('form'); + hiddenForm.action = url; + hiddenForm.method = 'POST'; + hiddenForm.target = target; const token = document.createElement('input'); token.type = 'hidden'; token.name = 'csrf_token'; token.value = (document.getElementById('csrf_token') || {}).value; - exploreForm.appendChild(token); + hiddenForm.appendChild(token); const data = document.createElement('input'); data.type = 'hidden'; data.name = 'form_data'; data.value = safeStringify(payload); - exploreForm.appendChild(data); + hiddenForm.appendChild(data); - document.body.appendChild(exploreForm); - exploreForm.submit(); - document.body.removeChild(exploreForm); + document.body.appendChild(hiddenForm); + hiddenForm.submit(); + document.body.removeChild(hiddenForm); +} + +export function exportChart(formData, endpointType) { + const { url, payload } = getExploreUrlAndPayload({ + formData, + endpointType, + allowDomainSharding: false, + }); + postForm(url, payload); } diff --git a/superset-frontend/src/explore/reducers/exploreReducer.js b/superset-frontend/src/explore/reducers/exploreReducer.js index 9d68510cdebf..df9b63d57ed4 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.js +++ b/superset-frontend/src/explore/reducers/exploreReducer.js @@ -131,9 +131,7 @@ export default function exploreReducer(state = {}, action) { }; }, [actions.UPDATE_CHART_TITLE]() { - const updatedSlice = Object.assign({}, state.slice, { - slice_name: action.slice_name, - }); + const updatedSlice = { ...state.slice, slice_name: action.slice_name }; return { ...state, slice: updatedSlice, diff --git a/superset-frontend/src/explore/reducers/saveModalReducer.js b/superset-frontend/src/explore/reducers/saveModalReducer.js index a73633fe26be..eee419799118 100644 --- a/superset-frontend/src/explore/reducers/saveModalReducer.js +++ b/superset-frontend/src/explore/reducers/saveModalReducer.js @@ -22,23 +22,22 @@ import * as actions from '../actions/saveModalActions'; export default function saveModalReducer(state = {}, action) { const actionHandlers = { [actions.FETCH_DASHBOARDS_SUCCEEDED]() { - return Object.assign({}, state, { dashboards: action.choices }); + return { ...state, dashboards: action.choices }; }, [actions.FETCH_DASHBOARDS_FAILED]() { - return Object.assign({}, state, { + return { + ...state, saveModalAlert: `fetching dashboards failed for ${action.userId}`, - }); + }; }, [actions.SAVE_SLICE_FAILED]() { - return Object.assign({}, state, { - saveModalAlert: 'Failed to save slice', - }); + return { ...state, saveModalAlert: 'Failed to save slice' }; }, [actions.SAVE_SLICE_SUCCESS](data) { - return Object.assign({}, state, { data }); + return { ...state, data }; }, [actions.REMOVE_SAVE_MODAL_ALERT]() { - return Object.assign({}, state, { saveModalAlert: null }); + return { ...state, saveModalAlert: null }; }, }; diff --git a/superset-frontend/src/explore/store.js b/superset-frontend/src/explore/store.js index 793a31bc80ea..92cd4f7bacb1 100644 --- a/superset-frontend/src/explore/store.js +++ b/superset-frontend/src/explore/store.js @@ -41,7 +41,7 @@ export function getControlsState(state, inputFormData) { * */ // Getting a list of active control names for the current viz - const formData = Object.assign({}, inputFormData); + const formData = { ...inputFormData }; const vizType = formData.viz_type || 'table'; handleDeprecatedControls(formData); @@ -80,7 +80,7 @@ export function applyDefaultFormData(inputFormData) { return formData; } -const defaultControls = Object.assign({}, controls); +const defaultControls = { ...controls }; Object.keys(controls).forEach(f => { defaultControls[f].value = controls[f].default; }); diff --git a/superset-frontend/src/featureFlags.ts b/superset-frontend/src/featureFlags.ts index 08535787dc55..b9d1e385e235 100644 --- a/superset-frontend/src/featureFlags.ts +++ b/superset-frontend/src/featureFlags.ts @@ -26,6 +26,7 @@ export enum FeatureFlag { ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST', SHARE_QUERIES_VIA_KV_STORE = 'SHARE_QUERIES_VIA_KV_STORE', SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE', + LIST_VIEWS_NEW_UI = 'LIST_VIEWS_NEW_UI', } export type FeatureFlagMap = { diff --git a/superset-frontend/src/preamble.js b/superset-frontend/src/preamble.js index b88ceaadf833..69324f45f425 100644 --- a/superset-frontend/src/preamble.js +++ b/superset-frontend/src/preamble.js @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { setConfig as setHotLoaderConfig } from 'react-hot-loader'; import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'; import moment from 'moment'; import { configure } from '@superset-ui/translation'; @@ -23,6 +24,10 @@ import setupClient from './setup/setupClient'; import setupColors from './setup/setupColors'; import setupFormatters from './setup/setupFormatters'; +if (process.env.WEBPACK_MODE === 'development') { + setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false }); +} + // Configure translation if (typeof window !== 'undefined') { const root = document.getElementById('app'); diff --git a/superset-frontend/src/profile/components/App.jsx b/superset-frontend/src/profile/components/App.jsx index 1d8ea8a6fff7..457a46e3d990 100644 --- a/superset-frontend/src/profile/components/App.jsx +++ b/superset-frontend/src/profile/components/App.jsx @@ -49,7 +49,9 @@ export default function App(props) { } > - + + + - + + + - + + + - + + + diff --git a/superset-frontend/src/profile/components/UserInfo.jsx b/superset-frontend/src/profile/components/UserInfo.jsx index 2a2146e2295b..1c6adca6d9e9 100644 --- a/superset-frontend/src/profile/components/UserInfo.jsx +++ b/superset-frontend/src/profile/components/UserInfo.jsx @@ -41,31 +41,33 @@ const UserInfo = ({ user }) => (
-

- - {user.firstName} {user.lastName} - -

-

- {user.username} -

-
-

- {t('joined')}{' '} - {moment(user.createdOn, 'YYYYMMDD').fromNow()} -

-

- {user.email} -

-

- {Object.keys(user.roles).join(', ')} -

-

- -   - {t('id:')}  - {user.userId} -

+ +

+ + {user.firstName} {user.lastName} + +

+

+ {user.username} +

+
+

+ {t('joined')}{' '} + {moment(user.createdOn, 'YYYYMMDD').fromNow()} +

+

+ {user.email} +

+

+ {Object.keys(user.roles).join(', ')} +

+

+ +   + {t('id:')}  + {user.userId} +

+
); diff --git a/superset-frontend/src/reduxUtils.js b/superset-frontend/src/reduxUtils.js index 7190372f3404..8538f25cec74 100644 --- a/superset-frontend/src/reduxUtils.js +++ b/superset-frontend/src/reduxUtils.js @@ -22,20 +22,20 @@ import persistState from 'redux-localstorage'; import { isEqual } from 'lodash'; export function addToObject(state, arrKey, obj) { - const newObject = Object.assign({}, state[arrKey]); - const copiedObject = Object.assign({}, obj); + const newObject = { ...state[arrKey] }; + const copiedObject = { ...obj }; if (!copiedObject.id) { copiedObject.id = shortid.generate(); } newObject[copiedObject.id] = copiedObject; - return Object.assign({}, state, { [arrKey]: newObject }); + return { ...state, [arrKey]: newObject }; } export function alterInObject(state, arrKey, obj, alterations) { - const newObject = Object.assign({}, state[arrKey]); - newObject[obj.id] = Object.assign({}, newObject[obj.id], alterations); - return Object.assign({}, state, { [arrKey]: newObject }); + const newObject = { ...state[arrKey] }; + newObject[obj.id] = { ...newObject[obj.id], ...alterations }; + return { ...state, [arrKey]: newObject }; } export function alterInArr(state, arrKey, obj, alterations, idKey = 'id') { @@ -44,12 +44,12 @@ export function alterInArr(state, arrKey, obj, alterations, idKey = 'id') { const newArr = []; state[arrKey].forEach(arrItem => { if (obj[idKey] === arrItem[idKey]) { - newArr.push(Object.assign({}, arrItem, alterations)); + newArr.push({ ...arrItem, ...alterations }); } else { newArr.push(arrItem); } }); - return Object.assign({}, state, { [arrKey]: newArr }); + return { ...state, [arrKey]: newArr }; } export function removeFromArr(state, arrKey, obj, idKey = 'id') { @@ -59,7 +59,7 @@ export function removeFromArr(state, arrKey, obj, idKey = 'id') { newArr.push(arrItem); } }); - return Object.assign({}, state, { [arrKey]: newArr }); + return { ...state, [arrKey]: newArr }; } export function getFromArr(arr, id) { @@ -73,7 +73,7 @@ export function getFromArr(arr, id) { } export function addToArr(state, arrKey, obj, prepend = false) { - const newObj = Object.assign({}, obj); + const newObj = { ...obj }; if (!newObj.id) { newObj.id = shortid.generate(); } @@ -83,7 +83,7 @@ export function addToArr(state, arrKey, obj, prepend = false) { } else { newState[arrKey] = [...state[arrKey], newObj]; } - return Object.assign({}, state, newState); + return { ...state, ...newState }; } export function extendArr(state, arrKey, obj, prepend = false) { @@ -100,7 +100,7 @@ export function extendArr(state, arrKey, obj, prepend = false) { } else { newState[arrKey] = [...state[arrKey], ...newObj]; } - return Object.assign({}, state, newState); + return { ...state, ...newState }; } export function initEnhancer(persist = true, persistConfig = {}) { diff --git a/superset-frontend/src/types/react-table-config.d.ts b/superset-frontend/src/types/react-table-config.d.ts index a16a695643c2..5c2321a2412e 100644 --- a/superset-frontend/src/types/react-table-config.d.ts +++ b/superset-frontend/src/types/react-table-config.d.ts @@ -68,7 +68,6 @@ import { declare module 'react-table' { export interface TableOptions extends UseExpandedOptions, - UseFiltersOptions, UseFiltersOptions, UseGlobalFiltersOptions, UseGroupByOptions, diff --git a/superset-frontend/src/utils/getControlsForVizType.js b/superset-frontend/src/utils/getControlsForVizType.js new file mode 100644 index 000000000000..3be5b7edfc39 --- /dev/null +++ b/superset-frontend/src/utils/getControlsForVizType.js @@ -0,0 +1,46 @@ +/** + * 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 memoize from 'lodash/memoize'; +import { getChartControlPanelRegistry } from '@superset-ui/chart'; +import controls from '../explore/controls'; + +const getControlsForVizType = memoize(vizType => { + const controlsMap = {}; + getChartControlPanelRegistry() + .get(vizType) + .controlPanelSections.forEach(section => { + section.controlSetRows.forEach(row => { + row.forEach(control => { + if (!control) return; + if (typeof control === 'string') { + // For now, we have to look in controls.jsx to get the config for some controls. + // Once everything is migrated out, delete this if statement. + controlsMap[control] = controls[control]; + } else if (control.name && control.config) { + // condition needed because there are elements, e.g.
in some control configs (I'm looking at you, FilterBox!) + controlsMap[control.name] = control.config; + } + }); + }); + }); + return controlsMap; +}); + +export default getControlsForVizType; diff --git a/superset-frontend/src/utils/reducerUtils.js b/superset-frontend/src/utils/reducerUtils.js index 1059fe958d9b..7e550a5853cb 100644 --- a/superset-frontend/src/utils/reducerUtils.js +++ b/superset-frontend/src/utils/reducerUtils.js @@ -19,20 +19,20 @@ import shortid from 'shortid'; export function addToObject(state, arrKey, obj) { - const newObject = Object.assign({}, state[arrKey]); - const copiedObject = Object.assign({}, obj); + const newObject = { ...state[arrKey] }; + const copiedObject = { ...obj }; if (!copiedObject.id) { copiedObject.id = shortid.generate(); } newObject[copiedObject.id] = copiedObject; - return Object.assign({}, state, { [arrKey]: newObject }); + return { ...state, [arrKey]: newObject }; } export function alterInObject(state, arrKey, obj, alterations) { - const newObject = Object.assign({}, state[arrKey]); - newObject[obj.id] = Object.assign({}, newObject[obj.id], alterations); - return Object.assign({}, state, { [arrKey]: newObject }); + const newObject = { ...state[arrKey] }; + newObject[obj.id] = { ...newObject[obj.id], ...alterations }; + return { ...state, [arrKey]: newObject }; } export function alterInArr(state, arrKey, obj, alterations) { @@ -42,12 +42,12 @@ export function alterInArr(state, arrKey, obj, alterations) { const newArr = []; state[arrKey].forEach(arrItem => { if (obj[idKey] === arrItem[idKey]) { - newArr.push(Object.assign({}, arrItem, alterations)); + newArr.push({ ...arrItem, ...alterations }); } else { newArr.push(arrItem); } }); - return Object.assign({}, state, { [arrKey]: newArr }); + return { ...state, [arrKey]: newArr }; } export function removeFromArr(state, arrKey, obj, idKey = 'id') { @@ -57,15 +57,15 @@ export function removeFromArr(state, arrKey, obj, idKey = 'id') { newArr.push(arrItem); } }); - return Object.assign({}, state, { [arrKey]: newArr }); + return { ...state, [arrKey]: newArr }; } export function addToArr(state, arrKey, obj) { - const newObj = Object.assign({}, obj); + const newObj = { ...obj }; if (!newObj.id) { newObj.id = shortid.generate(); } const newState = {}; newState[arrKey] = [...state[arrKey], newObj]; - return Object.assign({}, state, newState); + return { ...state, ...newState }; } diff --git a/superset-frontend/src/views/chartList/ChartList.tsx b/superset-frontend/src/views/chartList/ChartList.tsx index 351f232c6bfd..5b1c5b42c305 100644 --- a/superset-frontend/src/views/chartList/ChartList.tsx +++ b/superset-frontend/src/views/chartList/ChartList.tsx @@ -18,6 +18,7 @@ */ import { SupersetClient } from '@superset-ui/connection'; import { t } from '@superset-ui/translation'; +import { getChartMetadataRegistry } from '@superset-ui/chart'; import moment from 'moment'; import PropTypes from 'prop-types'; import React from 'react'; @@ -33,6 +34,7 @@ import { import withToasts from 'src/messageToasts/enhancers/withToasts'; import PropertiesModal, { Slice } from 'src/explore/components/PropertiesModal'; import Chart from 'src/types/Chart'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; const PAGE_SIZE = 25; @@ -47,7 +49,6 @@ interface State { loading: boolean; filterOperators: FilterOperatorMap; filters: Filters; - owners: Array<{ text: string; value: number }>; lastFetchDataConfig: FetchDataConfig | null; permissions: string[]; // for now we need to use the Slice type defined in PropertiesModal. @@ -67,32 +68,31 @@ class ChartList extends React.PureComponent { filters: [], lastFetchDataConfig: null, loading: false, - owners: [], permissions: [], sliceCurrentlyEditing: null, }; componentDidMount() { - Promise.all([ - SupersetClient.get({ - endpoint: `/api/v1/chart/_info`, - }), - SupersetClient.get({ - endpoint: `/api/v1/chart/related/owners`, - }), - ]).then( - ([{ json: infoJson = {} }, { json: ownersJson = {} }]) => { + SupersetClient.get({ + endpoint: `/api/v1/chart/_info`, + }).then( + ({ json: infoJson = {} }) => { this.setState( { filterOperators: infoJson.filters, - owners: ownersJson.result, permissions: infoJson.permissions, }, this.updateFilters, ); }, ([e1, e2]) => { - this.props.addDangerToast(t('An error occurred while fetching Charts')); + this.props.addDangerToast( + t( + 'An error occurred while fetching charts: %s, %s', + e1.message, + e2.message, + ), + ); if (e1) { console.error(e1); } @@ -111,6 +111,10 @@ class ChartList extends React.PureComponent { return this.hasPerm('can_delete'); } + get isNewUIEnabled() { + return isFeatureEnabled(FeatureFlag.LIST_VIEWS_NEW_UI); + } + initialSort = [{ id: 'changed_on', desc: true }]; columns = [ @@ -175,6 +179,10 @@ class ChartList extends React.PureComponent { accessor: 'owners', hidden: true, }, + { + accessor: 'datasource', + hidden: true, + }, { Cell: ({ row: { state, original } }: any) => { const handleDelete = () => this.handleChartDelete(original); @@ -311,11 +319,27 @@ class ChartList extends React.PureComponent { }, loading: true, }); - const filterExps = filters.map(({ id: col, operator: opr, value }) => ({ - col, - opr, - value, - })); + const filterExps = filters + .map(({ id: col, operator: opr, value }) => ({ + col, + opr, + value, + })) + .reduce((acc, fltr) => { + if ( + fltr.col === 'datasource' && + fltr.value && + typeof fltr.value === 'object' + ) { + const { datasource_id: dsId, datasource_type: dsType } = fltr.value; + return [ + ...acc, + { ...fltr, col: 'datasource_id', value: dsId }, + { ...fltr, col: 'datasource_type', value: dsType }, + ]; + } + return [...acc, fltr]; + }, []); const queryParams = JSON.stringify({ order_column: sortBy[0].id, @@ -331,16 +355,83 @@ class ChartList extends React.PureComponent { .then(({ json = {} }) => { this.setState({ charts: json.result, chartCount: json.count }); }) - .catch(() => { - this.props.addDangerToast(t('An error occurred while fetching Charts')); + .catch(e => { + this.props.addDangerToast( + t('An error occurred while fetching charts: %s', e.message), + ); }) .finally(() => { this.setState({ loading: false }); }); }; - updateFilters = () => { - const { filterOperators, owners } = this.state; + createFetchResource = ( + resource: string, + postProcess?: (value: []) => any[], + ) => async () => { + try { + const { json = {} } = await SupersetClient.get({ + endpoint: resource, + }); + return postProcess ? postProcess(json?.result) : json?.result; + } catch (e) { + this.props.addDangerToast( + t('An error occurred while fetching chart filters: %s', e.message), + ); + } + return []; + }; + + convertOwners = (owners: any[]) => + owners.map(({ text: label, value }) => ({ label, value })); + + updateFilters = async () => { + const { filterOperators } = this.state; + const fetchOwners = this.createFetchResource( + '/api/v1/chart/related/owners', + this.convertOwners, + ); + + if (this.isNewUIEnabled) { + this.setState({ + filters: [ + { + Header: 'Owner', + id: 'owners', + input: 'select', + operator: 'rel_m_m', + unfilteredLabel: 'All', + fetchSelects: fetchOwners, + }, + { + Header: 'Viz Type', + id: 'viz_type', + input: 'select', + operator: 'eq', + unfilteredLabel: 'All', + selects: getChartMetadataRegistry() + .keys() + .map(k => ({ label: k, value: k })), + }, + { + Header: 'Dataset', + id: 'datasource', + input: 'select', + operator: 'eq', + unfilteredLabel: 'All', + fetchSelects: this.createFetchResource('/api/v1/chart/datasources'), + }, + { + Header: 'Search', + id: 'slice_name', + input: 'search', + operator: 'name_or_description', + }, + ], + }); + return; + } + const convertFilter = ({ name: label, operator, @@ -349,6 +440,7 @@ class ChartList extends React.PureComponent { operator: string; }) => ({ label, value: operator }); + const owners = await fetchOwners(); this.setState({ filters: [ { @@ -376,7 +468,7 @@ class ChartList extends React.PureComponent { id: 'owners', input: 'select', operators: filterOperators.owners.map(convertFilter), - selects: owners.map(({ text: label, value }) => ({ label, value })), + selects: owners, }, ], }); @@ -393,51 +485,54 @@ class ChartList extends React.PureComponent { return (
- {sliceCurrentlyEditing && ( - - )} - + {sliceCurrentlyEditing && ( + )} - onConfirm={this.handleBulkChartDelete} - > - {confirmDelete => { - const bulkActions = []; - if (this.canDelete) { - bulkActions.push({ - key: 'delete', - name: ( - <> - Delete - - ), - onSelect: confirmDelete, - }); - } - return ( - - ); - }} - + + {confirmDelete => { + const bulkActions = []; + if (this.canDelete) { + bulkActions.push({ + key: 'delete', + name: ( + <> + Delete + + ), + onSelect: confirmDelete, + }); + } + return ( + + ); + }} + +
); diff --git a/superset-frontend/src/views/dashboardList/DashboardList.tsx b/superset-frontend/src/views/dashboardList/DashboardList.tsx index 38b7d2b2babb..0137c9ef023c 100644 --- a/superset-frontend/src/views/dashboardList/DashboardList.tsx +++ b/superset-frontend/src/views/dashboardList/DashboardList.tsx @@ -32,6 +32,7 @@ import { } from 'src/components/ListView/types'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; const PAGE_SIZE = 25; @@ -125,6 +126,10 @@ class DashboardList extends React.PureComponent { return this.hasPerm('can_mulexport'); } + get isNewUIEnabled() { + return isFeatureEnabled(FeatureFlag.LIST_VIEWS_NEW_UI); + } + initialSort = [{ id: 'changed_on', desc: true }]; columns = [ @@ -378,6 +383,39 @@ class DashboardList extends React.PureComponent { updateFilters = () => { const { filterOperators, owners } = this.state; + + if (this.isNewUIEnabled) { + return this.setState({ + filters: [ + { + Header: 'Owner', + id: 'owners', + input: 'select', + operator: 'rel_m_m', + unfilteredLabel: 'All', + selects: owners.map(({ text: label, value }) => ({ label, value })), + }, + { + Header: 'Published', + id: 'published', + input: 'select', + operator: 'eq', + unfilteredLabel: 'Any', + selects: [ + { label: 'Published', value: true }, + { label: 'Unpublished', value: false }, + ], + }, + { + Header: 'Search', + id: 'dashboard_title', + input: 'search', + operator: 'title_or_slug', + }, + ], + }); + } + const convertFilter = ({ name: label, operator, @@ -386,7 +424,7 @@ class DashboardList extends React.PureComponent { operator: string; }) => ({ label, value: operator }); - this.setState({ + return this.setState({ filters: [ { Header: 'Dashboard', @@ -427,64 +465,67 @@ class DashboardList extends React.PureComponent { return (
- - {confirmDelete => { - const bulkActions = []; - if (this.canDelete) { - bulkActions.push({ - key: 'delete', - name: ( - <> - Delete - - ), - onSelect: confirmDelete, - }); - } - if (this.canExport) { - bulkActions.push({ - key: 'export', - name: ( - <> - Export - - ), - onSelect: this.handleBulkDashboardExport, - }); - } - return ( - <> - {dashboardToEdit && ( - this.setState({ dashboardToEdit: null })} - onDashboardSave={this.handleDashboardEdit} + + + {confirmDelete => { + const bulkActions = []; + if (this.canDelete) { + bulkActions.push({ + key: 'delete', + name: ( + <> + Delete + + ), + onSelect: confirmDelete, + }); + } + if (this.canExport) { + bulkActions.push({ + key: 'export', + name: ( + <> + Export + + ), + onSelect: this.handleBulkDashboardExport, + }); + } + return ( + <> + {dashboardToEdit && ( + this.setState({ dashboardToEdit: null })} + onDashboardSave={this.handleDashboardEdit} + /> + )} + - )} - - - ); - }} - + + ); + }} + +
); diff --git a/superset-frontend/src/views/datasetList/DatasetList.tsx b/superset-frontend/src/views/datasetList/DatasetList.tsx index 62821e669749..43821b0efdaa 100644 --- a/superset-frontend/src/views/datasetList/DatasetList.tsx +++ b/superset-frontend/src/views/datasetList/DatasetList.tsx @@ -110,7 +110,7 @@ class DatasetList extends React.PureComponent { }, ([e1, e2]) => { this.props.addDangerToast( - t('An error occurred while fetching Datasets'), + t('An error occurred while fetching datasets'), ); if (e1) { console.error(e1); @@ -326,7 +326,7 @@ class DatasetList extends React.PureComponent { }) .catch(() => { this.props.addDangerToast( - t('An error occurred while fetching Datasets'), + t('An error occurred while fetching datasets'), ); }) .finally(() => { @@ -389,56 +389,58 @@ class DatasetList extends React.PureComponent { return (
- - {confirmDelete => { - const bulkActions = []; - if (this.canDelete) { - bulkActions.push({ - key: 'delete', - name: ( - <> - Delete - - ), - onSelect: confirmDelete, - }); - } - return ( - <> - {this.canCreate && ( - - - - - - )} - - - ); - }} - + + + {confirmDelete => { + const bulkActions = []; + if (this.canDelete) { + bulkActions.push({ + key: 'delete', + name: ( + <> + Delete + + ), + onSelect: confirmDelete, + }); + } + return ( + <> + {this.canCreate && ( + + + + + + )} + + + ); + }} + +
); diff --git a/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx b/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx index 036465c66e25..4f6f63c5e8e3 100644 --- a/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx +++ b/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx @@ -120,14 +120,15 @@ class FilterBox extends React.Component { getControlData(controlName) { const { selectedValues } = this.state; - const control = Object.assign({}, controls[controlName], { + const control = { + ...controls[controlName], // TODO: make these controls ('druid_time_origin', 'granularity', 'granularity_sqla', 'time_grain_sqla') accessible from getControlsForVizType. name: controlName, key: `control-${controlName}`, value: selectedValues[TIME_FILTER_MAP[controlName]], actions: { setControlValue: this.changeFilter }, - }); + }; const mapFunc = control.mapStateToProps; - return mapFunc ? Object.assign({}, control, mapFunc(this.props)) : control; + return mapFunc ? { ...control, ...mapFunc(this.props) } : control; } clickApply() { diff --git a/superset-frontend/src/visualizations/presets/MainPreset.js b/superset-frontend/src/visualizations/presets/MainPreset.js index f543fe34bc68..12d39c096bae 100644 --- a/superset-frontend/src/visualizations/presets/MainPreset.js +++ b/superset-frontend/src/visualizations/presets/MainPreset.js @@ -60,7 +60,7 @@ import { LineMultiChartPlugin, PieChartPlugin, TimePivotChartPlugin, -} from '@superset-ui/legacy-preset-chart-nvd3/lib'; +} from '@superset-ui/legacy-preset-chart-nvd3'; import { BoxPlotChartPlugin } from '@superset-ui/preset-chart-xy/esm/legacy'; import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl'; diff --git a/superset-frontend/src/welcome/App.jsx b/superset-frontend/src/welcome/App.jsx index a83f6df63a2a..696f7b2e9b2e 100644 --- a/superset-frontend/src/welcome/App.jsx +++ b/superset-frontend/src/welcome/App.jsx @@ -22,7 +22,10 @@ import thunk from 'redux-thunk'; import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; import { Provider } from 'react-redux'; import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; +import { ThemeProvider } from 'emotion-theming'; +import { initFeatureFlags } from 'src/featureFlags'; +import { supersetTheme } from 'stylesheets/styled-components/superset-theme'; import Menu from 'src/components/Menu/Menu'; import DashboardList from 'src/views/dashboardList/DashboardList'; import ChartList from 'src/views/chartList/ChartList'; @@ -31,16 +34,20 @@ import DatasetList from 'src/views/datasetList/DatasetList'; import messageToastReducer from '../messageToasts/reducers'; import { initEnhancer } from '../reduxUtils'; import setupApp from '../setup/setupApp'; +import setupPlugins from '../setup/setupPlugins'; import Welcome from './Welcome'; import ToastPresenter from '../messageToasts/containers/ToastPresenter'; setupApp(); +setupPlugins(); const container = document.getElementById('app'); const bootstrap = JSON.parse(container.getAttribute('data-bootstrap')); const user = { ...bootstrap.user }; const menu = { ...bootstrap.common.menu_data }; +initFeatureFlags(bootstrap.common.feature_flags); + const store = createStore( combineReducers({ messageToasts: messageToastReducer, @@ -51,24 +58,26 @@ const store = createStore( const App = () => ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); diff --git a/superset-frontend/src/welcome/Welcome.jsx b/superset-frontend/src/welcome/Welcome.jsx index 3b7a2f101db3..97d17f91deb1 100644 --- a/superset-frontend/src/welcome/Welcome.jsx +++ b/superset-frontend/src/welcome/Welcome.jsx @@ -63,45 +63,51 @@ export default function Welcome({ user }) { > - - -

{t('Dashboards')}

- - - setSearchQuery(e.currentTarget.value)} - /> - -
-
- + + + +

{t('Dashboards')}

+ + + setSearchQuery(e.currentTarget.value)} + /> + +
+
+ +
- - -

{t('Recently Viewed')}

- -
-
- + + + +

{t('Recently Viewed')}

+ +
+
+ +
- - -

{t('Favorites')}

- -
-
- + + + +

{t('Favorites')}

+ +
+
+ +
diff --git a/superset-frontend/stylesheets/fonts/InterUI/specimen.less b/superset-frontend/stylesheets/fonts/InterUI/specimen.less deleted file mode 100644 index 4d18941ec236..000000000000 --- a/superset-frontend/stylesheets/fonts/InterUI/specimen.less +++ /dev/null @@ -1,210 +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. - */ - -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 100; - font-display: swap; - src: url("./Inter-Thin.woff2?v=3.12") format("woff2"), - url("./Inter-Thin.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 100; - font-display: swap; - src: url("./Inter-ThinItalic.woff2?v=3.12") format("woff2"), - url("./Inter-ThinItalic.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 200; - font-display: swap; - src: url("./Inter-ExtraLight.woff2?v=3.12") format("woff2"), - url("./Inter-ExtraLight.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 200; - font-display: swap; - src: url("./Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"), - url("./Inter-ExtraLightItalic.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 300; - font-display: swap; - src: url("./Inter-Light.woff2?v=3.12") format("woff2"), - url("./Inter-Light.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 300; - font-display: swap; - src: url("./Inter-LightItalic.woff2?v=3.12") format("woff2"), - url("./Inter-LightItalic.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url("./Inter-Regular.woff2?v=3.12") format("woff2"), - url("./Inter-Regular.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 400; - font-display: swap; - src: url("./Inter-Italic.woff2?v=3.12") format("woff2"), - url("./Inter-Italic.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url("./Inter-Medium.woff2?v=3.12") format("woff2"), - url("./Inter-Medium.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 500; - font-display: swap; - src: url("./Inter-MediumItalic.woff2?v=3.12") format("woff2"), - url("./Inter-MediumItalic.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url("./Inter-SemiBold.woff2?v=3.12") format("woff2"), - url("./Inter-SemiBold.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 600; - font-display: swap; - src: url("./Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"), - url("./Inter-SemiBoldItalic.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url("./Inter-Bold.woff2?v=3.12") format("woff2"), - url("./Inter-Bold.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 700; - font-display: swap; - src: url("./Inter-BoldItalic.woff2?v=3.12") format("woff2"), - url("./Inter-BoldItalic.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 800; - font-display: swap; - src: url("./Inter-ExtraBold.woff2?v=3.12") format("woff2"), - url("./Inter-ExtraBold.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 800; - font-display: swap; - src: url("./Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"), - url("./Inter-ExtraBoldItalic.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: normal; - font-weight: 900; - font-display: swap; - src: url("./Inter-Black.woff2?v=3.12") format("woff2"), - url("./Inter-Black.woff?v=3.12") format("woff"); -} -@font-face { - font-family: 'Inter'; - font-style: italic; - font-weight: 900; - font-display: swap; - src: url("./Inter-BlackItalic.woff2?v=3.12") format("woff2"), - url("./Inter-BlackItalic.woff?v=3.12") format("woff"); -} - -/* ------------------------------------------------------- -Variable font. -Usage: - - html { font-family: 'Inter', sans-serif; } - @supports (font-variation-settings: normal) { - html { font-family: 'Inter var', sans-serif; } - } -*/ -@font-face { - font-family: 'Inter var'; - font-weight: 100 900; - font-display: swap; - font-style: normal; - font-named-instance: 'Regular'; - src: url("./Inter-roman.var.woff2?v=3.12") format("woff2"); -} -@font-face { - font-family: 'Inter var'; - font-weight: 100 900; - font-display: swap; - font-style: italic; - font-named-instance: 'Italic'; - src: url("./Inter-italic.var.woff2?v=3.12") format("woff2"); -} - -/* -------------------------------------------------------------------------- -[EXPERIMENTAL] Multi-axis, single variable font. - -Slant axis is not yet widely supported (as of February 2019) and thus this -multi-axis single variable font is opt-in rather than the default. - -When using this, you will probably need to set font-variation-settings -explicitly, e.g. - - * { font-variation-settings: "slnt" 0deg } - .italic { font-variation-settings: "slnt" 10deg } - -*/ -@font-face { - font-family: 'Inter var experimental'; - font-weight: 100 900; - font-display: swap; - font-style: oblique 0deg 10deg; - src: url("./Inter.var.woff2?v=3.12") format("woff2"); -} diff --git a/superset-frontend/stylesheets/less/font_specimens/fira_code.less b/superset-frontend/stylesheets/less/font_specimens/fira_code.less new file mode 100644 index 000000000000..6b310da7d373 --- /dev/null +++ b/superset-frontend/stylesheets/less/font_specimens/fira_code.less @@ -0,0 +1,63 @@ +/** + * 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. + */ + +@font-face { + font-family: 'Fira Code'; + src: url('../../../fonts/FiraCode/woff2/FiraCode-Light.woff2') format('woff2'), + url('../../../fonts/FiraCode/woff/FiraCode-Light.woff') format('woff'); + font-weight: 300; + font-style: normal; +} + +@font-face { + font-family: 'Fira Code'; + src: url('../../../fonts/FiraCode/woff2/FiraCode-Regular.woff2') + format('woff2'), + url('../../../fonts/FiraCode/woff/FiraCode-Regular.woff') format('woff'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Fira Code'; + src: url('../../../fonts/FiraCode/woff2/FiraCode-Medium.woff2') + format('woff2'), + url('../../../fonts/FiraCode/woff/FiraCode-Medium.woff') format('woff'); + font-weight: 500; + font-style: normal; +} + +@font-face { + font-family: 'Fira Code'; + src: url('../../../fonts/FiraCode/woff2/FiraCode-Bold.woff2') format('woff2'), + url('../../../fonts/FiraCode/woff/FiraCode-Bold.woff') format('woff'); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: 'Fira Code VF'; + src: url('../../../fonts/FiraCode/woff2/FiraCode-VF.woff2') + format('woff2-variations'), + url('../../../fonts/FiraCode/woff/FiraCode-VF.woff') + format('woff-variations'); + /* font-weight requires a range: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide#Using_a_variable_font_font-face_changes */ + font-weight: 300 700; + font-style: normal; +} diff --git a/superset-frontend/stylesheets/less/font_specimens/inter_ui.less b/superset-frontend/stylesheets/less/font_specimens/inter_ui.less new file mode 100644 index 000000000000..26a56e20ac1f --- /dev/null +++ b/superset-frontend/stylesheets/less/font_specimens/inter_ui.less @@ -0,0 +1,225 @@ +/** + * 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. + */ + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-Thin.woff2?v=3.12') format('woff2'), + url('../../../fonts/InterUI/Inter-Thin.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-ThinItalic.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-ThinItalic.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-ExtraLight.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-ExtraLight.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-ExtraLightItalic.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-ExtraLightItalic.woff?v=3.12') + format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-Light.woff2?v=3.12') format('woff2'), + url('../../../fonts/InterUI/Inter-Light.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-LightItalic.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-LightItalic.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-Regular.woff2?v=3.12') format('woff2'), + url('../../../fonts/InterUI/Inter-Regular.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-Italic.woff2?v=3.12') format('woff2'), + url('../../../fonts/InterUI/Inter-Italic.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-Medium.woff2?v=3.12') format('woff2'), + url('../../../fonts/InterUI/Inter-Medium.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-MediumItalic.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-MediumItalic.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-SemiBold.woff2?v=3.12') format('woff2'), + url('../../../fonts/InterUI/Inter-SemiBold.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-SemiBoldItalic.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-SemiBoldItalic.woff?v=3.12') + format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-Bold.woff2?v=3.12') format('woff2'), + url('../../../fonts/InterUI/Inter-Bold.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-BoldItalic.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-BoldItalic.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-ExtraBold.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-ExtraBold.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-ExtraBoldItalic.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-ExtraBoldItalic.woff?v=3.12') + format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-Black.woff2?v=3.12') format('woff2'), + url('../../../fonts/InterUI/Inter-Black.woff?v=3.12') format('woff'); +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: url('../../../fonts/InterUI/Inter-BlackItalic.woff2?v=3.12') + format('woff2'), + url('../../../fonts/InterUI/Inter-BlackItalic.woff?v=3.12') format('woff'); +} + +/* ------------------------------------------------------- +Variable font. +Usage: + + html { font-family: 'Inter', sans-serif; } + @supports (font-variation-settings: normal) { + html { font-family: 'Inter var', sans-serif; } + } +*/ +@font-face { + font-family: 'Inter var'; + font-weight: 100 900; + font-display: swap; + font-style: normal; + font-named-instance: 'Regular'; + src: url('../../../fonts/InterUI/Inter-roman.var.woff2?v=3.12') + format('woff2'); +} +@font-face { + font-family: 'Inter var'; + font-weight: 100 900; + font-display: swap; + font-style: italic; + font-named-instance: 'Italic'; + src: url('../../../fonts/InterUI/Inter-italic.var.woff2?v=3.12') + format('woff2'); +} + +/* -------------------------------------------------------------------------- +[EXPERIMENTAL] Multi-axis, single variable font. + +Slant axis is not yet widely supported (as of February 2019) and thus this +multi-axis single variable font is opt-in rather than the default. + +When using this, you will probably need to set font-variation-settings +explicitly, e.g. + + * { font-variation-settings: "slnt" 0deg } + .italic { font-variation-settings: "slnt" 10deg } + +*/ +@font-face { + font-family: 'Inter var experimental'; + font-weight: 100 900; + font-display: swap; + font-style: oblique 0deg 10deg; + src: url('../../../fonts/InterUI/Inter.var.woff2?v=3.12') format('woff2'); +} diff --git a/superset-frontend/stylesheets/less/fonts.less b/superset-frontend/stylesheets/less/fonts.less index f07313472c5b..822763c43815 100644 --- a/superset-frontend/stylesheets/less/fonts.less +++ b/superset-frontend/stylesheets/less/fonts.less @@ -17,7 +17,6 @@ * under the License. */ - /*************************************************************************/ /* USAGE NOTES */ /* Each typeface used in Superset should have local webfont files. */ @@ -26,7 +25,7 @@ /*************************************************************************/ /******************************* Inter UI ********************************/ -@import '../fonts/InterUI/specimen.less'; +@import './font_specimens/inter_ui.less'; /******************************* Fira Code ********************************/ -@import '../fonts/FiraCode/specimen.less'; +@import './font_specimens/fira_code.less'; diff --git a/superset-frontend/stylesheets/less/variables.less b/superset-frontend/stylesheets/less/variables.less index 0cfbc175981e..596cf15c438f 100644 --- a/superset-frontend/stylesheets/less/variables.less +++ b/superset-frontend/stylesheets/less/variables.less @@ -156,12 +156,16 @@ @use-ligatures: false; // setting up OTF settings based on @use-ligatures: -.set-otf-options(@use-ligatures); -.set-otf-options(true) {@font-feature-settings: "liga" on, "calt" on} -.set-otf-options(false) {@font-feature-settings: "liga" off, "calt" off} +.set-otf-options(@use-ligatures); +.set-otf-options(true) { + @font-feature-settings: 'liga' on, 'calt' on; +} +.set-otf-options(false) { + @font-feature-settings: 'liga' off, 'calt' off; +} // ****************************** Families ****************************** -@font-family-sans-serif: "Inter", Helvetica, Arial; +@font-family-sans-serif: 'Inter', Helvetica, Arial; @font-family-serif: Georgia, 'Times New Roman', Times, serif; @font-family-monospace: 'Fira Code', 'Courier New', monospace; @font-family-base: @font-family-sans-serif; diff --git a/superset-frontend/src/explore/controlPanels/Partition.js b/superset-frontend/stylesheets/styled-components/superset-theme.ts similarity index 57% rename from superset-frontend/src/explore/controlPanels/Partition.js rename to superset-frontend/stylesheets/styled-components/superset-theme.ts index 22d7709bb9bd..aadb575700c2 100644 --- a/superset-frontend/src/explore/controlPanels/Partition.js +++ b/superset-frontend/stylesheets/styled-components/superset-theme.ts @@ -16,28 +16,29 @@ * specific language governing permissions and limitations * under the License. */ -import { t } from '@superset-ui/translation'; -import { NVD3TimeSeries } from './sections'; +import styled, { CreateStyled } from '@emotion/styled'; -export default { - controlPanelSections: [ - NVD3TimeSeries[0], - { - label: t('Time Series Options'), - expanded: true, - controlSetRows: [['time_series_option']], +const defaultTheme = { + borderRadius: '4px', + colors: { + primary: { + base: '#20A7C9', }, - { - label: t('Chart Options'), - expanded: true, - controlSetRows: [ - ['color_scheme', 'label_colors'], - ['number_format', 'date_time_format'], - ['partition_limit', 'partition_threshold'], - ['log_scale', 'equal_date_size'], - ['rich_tooltip'], - ], + secondary: { + base: '#444E7C', + dark1: '#363E63', + dark2: '#282E4A', + dark3: '#1B1F31', + light1: '#8E94B0', + light2: '#B4B8CA', + light3: '#D9DBE4', + light4: '#ECEEF2', + light5: '#F5F5F8', }, - NVD3TimeSeries[1], - ], + }, + gridUnit: '4px', }; + +export default styled as CreateStyled; + +export const supersetTheme = defaultTheme; diff --git a/superset-frontend/tsconfig.json b/superset-frontend/tsconfig.json index 1ca3ffaecc5e..3f4dce97a717 100644 --- a/superset-frontend/tsconfig.json +++ b/superset-frontend/tsconfig.json @@ -25,8 +25,10 @@ "include": [ "./src/**/*", "./spec/**/*", + // include the source code of each plugin "./node_modules/*superset-ui*/**/src/**/*", "./node_modules/*superset-ui*/**/types/**/*", + // and the type defs of their dependencies "./node_modules/*superset-ui*/**/node_modules/**/*.d.ts" ] } diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index c26ef0351b27..50233b987465 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -20,17 +20,17 @@ const fs = require('fs'); const path = require('path'); const webpack = require('webpack'); -const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') - .BundleAnalyzerPlugin; +const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const SpeedMeasurePlugin = require('speed-measure-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); -const WebpackAssetsManifest = require('webpack-assets-manifest'); +const ManifestPlugin = require('webpack-manifest-plugin'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const parsedArgs = require('yargs').argv; +const getProxyConfig = require('./webpack.proxy-config'); const packageConfig = require('./package.json'); // input dir @@ -60,14 +60,38 @@ if (isDevMode) { const plugins = [ // creates a manifest.json mapping of name to hashed output used in template files - new WebpackAssetsManifest({ - publicPath: true, + new ManifestPlugin({ + publicPath: output.publicPath, + seed: { app: 'superset' }, // This enables us to include all relevant files for an entry - entrypoints: true, + generate: (seed, files, entrypoints) => { + // Each entrypoint's chunk files in the format of + // { + // entry: { + // css: [], + // js: [] + // } + // } + const entryFiles = {}; + for (const [entry, chunks] of Object.entries(entrypoints)) { + entryFiles[entry] = { + css: chunks + .filter(x => x.endsWith('.css')) + .map(x => path.join(output.publicPath, x)), + js: chunks + .filter(x => x.endsWith('.js')) + .map(x => path.join(output.publicPath, x)), + }; + } + return { + ...seed, + entrypoints: entryFiles, + }; + }, // Also write to disk when using devServer // instead of only keeping manifest.json in memory // This is required to make devServer work with flask. - writeToDisk: isDevMode, + writeToFileEmit: isDevMode, }), // create fresh dist/ upon build @@ -127,7 +151,10 @@ const babelLoader = { loader: 'babel-loader', options: { cacheDirectory: true, + // disable gzip compression for cache files + // faster when there are millions of small files cacheCompression: false, + plugins: ['emotion'], }, }; @@ -172,6 +199,7 @@ const config = { alias: { src: path.resolve(APP_DIR, './src'), 'react-dom': '@hot-loader/react-dom', + stylesheets: path.resolve(APP_DIR, './stylesheets'), }, extensions: ['.ts', '.tsx', '.js', '.jsx'], symlinks: false, @@ -198,6 +226,15 @@ const config = { // type checking is done via fork-ts-checker-webpack-plugin happyPackMode: true, transpileOnly: true, + // must override compiler options here, even though we have set + // the same options in `tsconfig.json`, because they may still + // be overriden by `tsconfig.json` in node_modules subdirectories. + compilerOptions: { + esModuleInterop: false, + importHelpers: false, + module: 'esnext', + target: 'esnext', + }, }, }, ], @@ -205,7 +242,7 @@ const config = { { test: /\.jsx?$/, // include source code for plugins, but exclude node_modules within them - exclude: [/superset-ui.*\/node_modules\/.*/], + exclude: [/superset-ui.*\/node_modules\//], include: [new RegExp(`${APP_DIR}/src`), /superset-ui.*\/src/], use: [babelLoader], }, @@ -277,28 +314,17 @@ const config = { devtool: false, }; -let proxyConfig = {}; -const requireModule = module.require; - -function loadProxyConfig() { - try { - delete require.cache[require.resolve('./webpack.proxy-config')]; - proxyConfig = requireModule('./webpack.proxy-config'); - } catch (e) { - if (e.code !== 'ENOENT') { - console.error('\n>> Error loading proxy config:'); - console.trace(e); - } - } -} +let proxyConfig = getProxyConfig(); if (isDevMode) { config.devtool = 'eval-cheap-module-source-map'; config.devServer = { - before() { - loadProxyConfig(); - // hot reloading proxy config - fs.watch('./webpack.proxy-config.js', loadProxyConfig); + before(app, server, compiler) { + // load proxy config when manifest updates + const hook = compiler.hooks.webpackManifestPluginAfterEmit; + hook.tap('ManifestPlugin', manifest => { + proxyConfig = getProxyConfig(manifest); + }); }, historyApiFallback: true, hot: true, diff --git a/superset-frontend/webpack.proxy-config.js b/superset-frontend/webpack.proxy-config.js index a797a5cee56c..931356db745d 100644 --- a/superset-frontend/webpack.proxy-config.js +++ b/superset-frontend/webpack.proxy-config.js @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ /** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -17,9 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -const fs = require('fs'); const zlib = require('zlib'); -const path = require('path'); + // eslint-disable-next-line import/no-extraneous-dependencies const parsedArgs = require('yargs').argv; @@ -28,28 +26,8 @@ const backend = (supersetUrl || `http://localhost:${supersetPort}`).replace( '//+$/', '', ); // strip ending backslash -const MANIFEST_FILE = path.resolve( - __dirname, - '../superset/static/assets/manifest.json', -); -let manifestContent; let manifest; -function loadManifest() { - try { - const newContent = fs.readFileSync(MANIFEST_FILE, { encoding: 'utf-8' }); - if (!newContent || newContent === manifestContent) return; - manifestContent = newContent; - manifest = JSON.parse(manifestContent); - console.log(`${MANIFEST_FILE} loaded.`); - } catch (e) { - if (e.code !== 'ENOENT') { - console.error('\n>> Error loading manifest file:'); - console.trace(e); - } - } -} - function isHTML(res) { const CONTENT_TYPE_HEADER = 'content-type'; const contentType = res.getHeader @@ -63,10 +41,6 @@ function toDevHTML(originalHtml) { /(\s*)([\s\S]*)(<\/title>)/i, '$1[DEV] $2 $3', ); - // load manifest file only when needed - if (!manifest) { - loadManifest(); - } if (manifest) { const loaded = new Set(); // replace bundled asset files, HTML comment tags generated by Jinja macros @@ -152,32 +126,29 @@ function processHTML(proxyResponse, response) { }); } -// make sure the manifest file exists -fs.mkdirSync(path.dirname(MANIFEST_FILE), { recursive: true }); -fs.closeSync(fs.openSync(MANIFEST_FILE, 'as+')); -// watch it as webpack-dev-server updates it -fs.watch(MANIFEST_FILE, loadManifest); - -module.exports = { - context: '/', - target: backend, - hostRewrite: true, - changeOrigin: true, - cookieDomainRewrite: '', // remove cookie domain - selfHandleResponse: true, // so that the onProxyRes takes care of sending the response - onProxyRes(proxyResponse, request, response) { - try { - copyHeaders(proxyResponse, response); - if (isHTML(response)) { - processHTML(proxyResponse, response); - } else { - proxyResponse.pipe(response); +module.exports = newManifest => { + manifest = newManifest; + return { + context: '/', + target: backend, + hostRewrite: true, + changeOrigin: true, + cookieDomainRewrite: '', // remove cookie domain + selfHandleResponse: true, // so that the onProxyRes takes care of sending the response + onProxyRes(proxyResponse, request, response) { + try { + copyHeaders(proxyResponse, response); + if (isHTML(response)) { + processHTML(proxyResponse, response); + } else { + proxyResponse.pipe(response); + } + response.flushHeaders(); + } catch (e) { + response.setHeader('content-type', 'text/plain'); + response.write(`Error requesting ${request.path} from proxy:\n\n`); + response.end(e.stack); } - response.flushHeaders(); - } catch (e) { - response.setHeader('content-type', 'text/plain'); - response.write(`Error requesting ${request.path} from proxy:\n\n`); - response.end(e.stack); - } - }, + }, + }; }; diff --git a/superset/__init__.py b/superset/__init__.py index cc92f3d9cee6..3e26c3fb74b4 100644 --- a/superset/__init__.py +++ b/superset/__init__.py @@ -51,3 +51,4 @@ lambda: results_backend_manager.should_use_msgpack ) tables_cache = LocalProxy(lambda: cache_manager.tables_cache) +thumbnail_cache = LocalProxy(lambda: cache_manager.thumbnail_cache) diff --git a/superset/app.py b/superset/app.py index f1eace3e5d78..b1ce22c369f5 100644 --- a/superset/app.py +++ b/superset/app.py @@ -131,6 +131,7 @@ def init_views(self) -> None: Druid, ) from superset.datasets.api import DatasetRestApi + from superset.queries.api import QueryRestApi from superset.connectors.sqla.views import ( TableColumnInlineView, SqlMetricInlineView, @@ -150,7 +151,7 @@ def init_views(self) -> None: CssTemplateModelView, CssTemplateAsyncModelView, ) - from superset.views.chart.api import ChartRestApi + from superset.charts.api import ChartRestApi from superset.views.chart.views import SliceModelView, SliceAsync from superset.dashboards.api import DashboardRestApi from superset.views.dashboard.views import ( @@ -184,6 +185,7 @@ def init_views(self) -> None: appbuilder.add_api(DashboardRestApi) appbuilder.add_api(DatabaseRestApi) appbuilder.add_api(DatasetRestApi) + appbuilder.add_api(QueryRestApi) # # Setup regular views # diff --git a/superset/assets/spec/javascripts/sqllab/ExploreCtasResultsButton_spec.jsx b/superset/assets/spec/javascripts/sqllab/ExploreCtasResultsButton_spec.jsx new file mode 100644 index 000000000000..98c8c10cf7d9 --- /dev/null +++ b/superset/assets/spec/javascripts/sqllab/ExploreCtasResultsButton_spec.jsx @@ -0,0 +1,78 @@ +/** + * 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 configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { shallow } from 'enzyme'; +import sinon from 'sinon'; + +import sqlLabReducer from '../../../src/SqlLab/reducers/index'; +import ExploreCtasResultsButton from '../../../src/SqlLab/components/ExploreCtasResultsButton'; +import Button from '../../../src/components/Button'; + +describe('ExploreCtasResultsButton', () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + const initialState = { + sqlLab: { + ...sqlLabReducer(undefined, {}), + }, + common: { + conf: { SUPERSET_WEBSERVER_TIMEOUT: 45 }, + }, + }; + const store = mockStore(initialState); + const mockedProps = { + table: 'dummy_table', + schema: 'dummy_schema', + dbId: 123, + }; + const getExploreCtasResultsButtonWrapper = (props = mockedProps) => + shallow(<ExploreCtasResultsButton {...props} />, { + context: { store }, + }).dive(); + + it('renders', () => { + expect(React.isValidElement(<ExploreCtasResultsButton />)).toBe(true); + }); + + it('renders with props', () => { + expect(React.isValidElement(<s {...mockedProps} />)).toBe(true); + }); + + it('renders a Button', () => { + const wrapper = getExploreCtasResultsButtonWrapper(); + expect(wrapper.find(Button)).toHaveLength(1); + }); + + describe('datasourceName', () => { + it('should build viz options', () => { + const wrapper = getExploreCtasResultsButtonWrapper(); + const spy = sinon.spy(wrapper.instance(), 'buildVizOptions'); + wrapper.instance().buildVizOptions(); + expect(spy.returnValues[0]).toEqual({ + schema: 'dummy_schema', + dbId: 123, + templateParams: undefined, + datasourceName: 'dummy_table', + }); + }); + }); +}); diff --git a/superset/assets/src/SqlLab/components/ExploreCtasResultsButton.jsx b/superset/assets/src/SqlLab/components/ExploreCtasResultsButton.jsx new file mode 100644 index 000000000000..b90d351b3194 --- /dev/null +++ b/superset/assets/src/SqlLab/components/ExploreCtasResultsButton.jsx @@ -0,0 +1,131 @@ +/** + * 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 { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import Dialog from 'react-bootstrap-dialog'; +import { t } from '@superset-ui/translation'; + +import { exportChart } from '../../explore/exploreUtils'; +import * as actions from '../actions/sqlLab'; +import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger'; +import Button from '../../components/Button'; + +const propTypes = { + actions: PropTypes.object.isRequired, + table: PropTypes.string.isRequired, + schema: PropTypes.string, + dbId: PropTypes.number.isRequired, + errorMessage: PropTypes.string, + templateParams: PropTypes.string, +}; +const defaultProps = { + vizRequest: {}, +}; + +class ExploreCtasResultsButton extends React.PureComponent { + constructor(props) { + super(props); + this.visualize = this.visualize.bind(this); + this.onClick = this.onClick.bind(this); + } + onClick() { + this.visualize(); + } + + buildVizOptions() { + return { + datasourceName: this.props.table, + schema: this.props.schema, + dbId: this.props.dbId, + templateParams: this.props.templateParams, + }; + } + visualize() { + this.props.actions + .createCtasDatasource(this.buildVizOptions()) + .then(data => { + const formData = { + datasource: `${data.table_id}__table`, + metrics: ['count'], + groupby: [], + viz_type: 'table', + since: '100 years ago', + all_columns: [], + row_limit: 1000, + }; + this.props.actions.addInfoToast( + t('Creating a data source and creating a new tab'), + ); + + // open new window for data visualization + exportChart(formData); + }) + .catch(() => { + this.props.actions.addDangerToast( + this.props.errorMessage || t('An error occurred'), + ); + }); + } + render() { + return ( + <> + <Button + bsSize="small" + onClick={this.onClick} + tooltip={t('Explore the result set in the data exploration view')} + > + <InfoTooltipWithTrigger + icon="line-chart" + placement="top" + label="explore" + />{' '} + {t('Explore')} + </Button> + <Dialog + ref={el => { + this.dialog = el; + }} + /> + </> + ); + } +} +ExploreCtasResultsButton.propTypes = propTypes; +ExploreCtasResultsButton.defaultProps = defaultProps; + +function mapStateToProps({ sqlLab, common }) { + return { + errorMessage: sqlLab.errorMessage, + timeout: common.conf ? common.conf.SUPERSET_WEBSERVER_TIMEOUT : null, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(actions, dispatch), + }; +} + +export { ExploreCtasResultsButton }; +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ExploreCtasResultsButton); diff --git a/superset/charts/__init__.py b/superset/charts/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/charts/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/charts/api.py b/superset/charts/api.py new file mode 100644 index 000000000000..81e0d02a788d --- /dev/null +++ b/superset/charts/api.py @@ -0,0 +1,567 @@ +# 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 logging +from typing import Any, Dict + +import simplejson +from apispec import APISpec +from flask import g, make_response, redirect, request, Response, url_for +from flask_appbuilder.api import expose, protect, rison, safe +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_babel import gettext as _, ngettext +from werkzeug.wrappers import Response as WerkzeugResponse +from werkzeug.wsgi import FileWrapper + +from superset import is_feature_enabled, thumbnail_cache +from superset.charts.commands.bulk_delete import BulkDeleteChartCommand +from superset.charts.commands.create import CreateChartCommand +from superset.charts.commands.delete import DeleteChartCommand +from superset.charts.commands.exceptions import ( + ChartBulkDeleteFailedError, + ChartCreateFailedError, + ChartDeleteFailedError, + ChartForbiddenError, + ChartInvalidError, + ChartNotFoundError, + ChartUpdateFailedError, +) +from superset.charts.commands.update import UpdateChartCommand +from superset.charts.dao import ChartDAO +from superset.charts.filters import ChartFilter, ChartNameOrDescriptionFilter +from superset.charts.schemas import ( + CHART_DATA_SCHEMAS, + ChartDataQueryContextSchema, + ChartPostSchema, + ChartPutSchema, + get_delete_ids_schema, + thumbnail_query_schema, +) +from superset.constants import RouteMethod +from superset.exceptions import SupersetSecurityException +from superset.extensions import event_logger, security_manager +from superset.models.slice import Slice +from superset.tasks.thumbnails import cache_chart_thumbnail +from superset.utils.core import json_int_dttm_ser +from superset.utils.screenshots import ChartScreenshot +from superset.views.base_api import ( + BaseSupersetModelRestApi, + RelatedFieldFilter, + statsd_metrics, +) +from superset.views.filters import FilterRelatedOwners + +logger = logging.getLogger(__name__) + + +class ChartRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(Slice) + + resource_name = "chart" + allow_browser_login = True + + include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { + RouteMethod.EXPORT, + RouteMethod.RELATED, + "bulk_delete", # not using RouteMethod since locally defined + "data", + "viz_types", + "datasources", + } + class_permission_name = "SliceModelView" + show_columns = [ + "slice_name", + "description", + "owners.id", + "owners.username", + "owners.first_name", + "owners.last_name", + "dashboards.id", + "dashboards.dashboard_title", + "viz_type", + "params", + "cache_timeout", + ] + list_columns = [ + "id", + "slice_name", + "url", + "description", + "changed_by.username", + "changed_by_name", + "changed_by_url", + "changed_on", + "datasource_name_text", + "datasource_url", + "viz_type", + "params", + "cache_timeout", + "owners.id", + "owners.username", + "owners.first_name", + "owners.last_name", + ] + order_columns = [ + "slice_name", + "viz_type", + "datasource_name", + "changed_by_fk", + "changed_on", + ] + search_columns = ( + "slice_name", + "description", + "viz_type", + "datasource_name", + "datasource_id", + "datasource_type", + "owners", + ) + base_order = ("changed_on", "desc") + base_filters = [["id", ChartFilter, lambda: []]] + search_filters = {"slice_name": [ChartNameOrDescriptionFilter]} + + # Will just affect _info endpoint + edit_columns = ["slice_name"] + add_columns = edit_columns + + add_model_schema = ChartPostSchema() + edit_model_schema = ChartPutSchema() + + openapi_spec_tag = "Charts" + + order_rel_fields = { + "slices": ("slice_name", "asc"), + "owners": ("first_name", "asc"), + } + related_field_filters = { + "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) + } + allowed_rel_fields = {"owners"} + + def __init__(self) -> None: + if is_feature_enabled("THUMBNAILS"): + self.include_route_methods = self.include_route_methods | {"thumbnail"} + super().__init__() + + @expose("/", methods=["POST"]) + @protect() + @safe + @statsd_metrics + def post(self) -> Response: + """Creates a new Chart + --- + post: + description: >- + Create a new Chart + requestBody: + description: Chart schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + responses: + 201: + description: Chart added + content: + application/json: + schema: + type: object + properties: + id: + type: number + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + if not request.is_json: + return self.response_400(message="Request is not JSON") + item = self.add_model_schema.load(request.json) + # This validates custom Schema with custom validations + if item.errors: + return self.response_400(message=item.errors) + try: + new_model = CreateChartCommand(g.user, item.data).run() + return self.response(201, id=new_model.id, result=item.data) + except ChartInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except ChartCreateFailedError as ex: + logger.error(f"Error creating model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) + + @expose("/<pk>", methods=["PUT"]) + @protect() + @safe + @statsd_metrics + def put( # pylint: disable=too-many-return-statements, arguments-differ + self, pk: int + ) -> Response: + """Changes a Chart + --- + put: + description: >- + Changes a Chart + parameters: + - in: path + schema: + type: integer + name: pk + requestBody: + description: Chart schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + responses: + 200: + description: Chart changed + content: + application/json: + schema: + type: object + properties: + id: + type: number + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + if not request.is_json: + return self.response_400(message="Request is not JSON") + item = self.edit_model_schema.load(request.json) + # This validates custom Schema with custom validations + if item.errors: + return self.response_400(message=item.errors) + try: + changed_model = UpdateChartCommand(g.user, pk, item.data).run() + return self.response(200, id=changed_model.id, result=item.data) + except ChartNotFoundError: + return self.response_404() + except ChartForbiddenError: + return self.response_403() + except ChartInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except ChartUpdateFailedError as ex: + logger.error(f"Error updating model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) + + @expose("/<pk>", methods=["DELETE"]) + @protect() + @safe + @statsd_metrics + def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ + """Deletes a Chart + --- + delete: + description: >- + Deletes a Chart + parameters: + - in: path + schema: + type: integer + name: pk + responses: + 200: + description: Chart delete + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + DeleteChartCommand(g.user, pk).run() + return self.response(200, message="OK") + except ChartNotFoundError: + return self.response_404() + except ChartForbiddenError: + return self.response_403() + except ChartDeleteFailedError as ex: + logger.error(f"Error deleting model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) + + @expose("/", methods=["DELETE"]) + @protect() + @safe + @statsd_metrics + @rison(get_delete_ids_schema) + def bulk_delete( + self, **kwargs: Any + ) -> Response: # pylint: disable=arguments-differ + """Delete bulk Charts + --- + delete: + description: >- + Deletes multiple Charts in a bulk operation + parameters: + - in: query + name: q + content: + application/json: + schema: + type: array + items: + type: integer + responses: + 200: + description: Charts bulk delete + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + item_ids = kwargs["rison"] + try: + BulkDeleteChartCommand(g.user, item_ids).run() + return self.response( + 200, + message=ngettext( + f"Deleted %(num)d chart", + f"Deleted %(num)d charts", + num=len(item_ids), + ), + ) + except ChartNotFoundError: + return self.response_404() + except ChartForbiddenError: + return self.response_403() + except ChartBulkDeleteFailedError as ex: + return self.response_422(message=str(ex)) + + @expose("/data", methods=["POST"]) + @event_logger.log_this + @protect() + @safe + @statsd_metrics + def data(self) -> Response: + """ + Takes a query context constructed in the client and returns payload + data response for the given query. + --- + post: + description: >- + Takes a query context constructed in the client and returns payload data + response for the given query. + requestBody: + description: >- + A query context consists of a datasource from which to fetch data + and one or many query objects. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ChartDataQueryContextSchema" + responses: + 200: + description: Query result + content: + application/json: + schema: + $ref: "#/components/schemas/ChartDataResponseSchema" + 400: + $ref: '#/components/responses/400' + 500: + $ref: '#/components/responses/500' + """ + if not request.is_json: + return self.response_400(message="Request is not JSON") + try: + query_context, errors = ChartDataQueryContextSchema().load(request.json) + if errors: + return self.response_400( + message=_("Request is incorrect: %(error)s", error=errors) + ) + except KeyError: + return self.response_400(message="Request is incorrect") + try: + security_manager.assert_query_context_permission(query_context) + except SupersetSecurityException: + return self.response_401() + payload_json = query_context.get_payload() + response_data = simplejson.dumps( + {"result": payload_json}, default=json_int_dttm_ser, ignore_nan=True + ) + resp = make_response(response_data, 200) + resp.headers["Content-Type"] = "application/json; charset=utf-8" + return resp + + @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) + @protect() + @rison(thumbnail_query_schema) + @safe + @statsd_metrics + def thumbnail( + self, pk: int, digest: str, **kwargs: Dict[str, bool] + ) -> WerkzeugResponse: + """Get Chart thumbnail + --- + get: + description: Compute or get already computed chart thumbnail from cache + parameters: + - in: path + schema: + type: integer + name: pk + - in: path + schema: + type: string + name: sha + responses: + 200: + description: Chart thumbnail image + content: + image/*: + schema: + type: string + format: binary + 302: + description: Redirects to the current digest + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + chart = self.datamodel.get(pk, self._base_filters) + if not chart: + return self.response_404() + if kwargs["rison"].get("force", False): + cache_chart_thumbnail.delay(chart.id, force=True) + return self.response(202, message="OK Async") + # fetch the chart screenshot using the current user and cache if set + screenshot = ChartScreenshot(pk).get_from_cache(cache=thumbnail_cache) + # If not screenshot then send request to compute thumb to celery + if not screenshot: + cache_chart_thumbnail.delay(chart.id, force=True) + return self.response(202, message="OK Async") + # If digests + if chart.digest != digest: + return redirect( + url_for( + f"{self.__class__.__name__}.thumbnail", pk=pk, digest=chart.digest + ) + ) + return Response( + FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True + ) + + def add_apispec_components(self, api_spec: APISpec) -> None: + for chart_type in CHART_DATA_SCHEMAS: + api_spec.components.schema( + chart_type.__name__, schema=chart_type, + ) + super().add_apispec_components(api_spec) + + @expose("/datasources", methods=["GET"]) + @protect() + @safe + def datasources(self) -> Response: + """Get available datasources + --- + get: + responses: + 200: + description: charts unique datasource data + content: + application/json: + schema: + type: object + properties: + count: + type: integer + result: + type: object + properties: + label: + type: string + value: + type: object + properties: + database_id: + type: integer + database_type: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + datasources = ChartDAO.fetch_all_datasources() + if not datasources: + return self.response(200, count=0, result=[]) + + result = [ + { + "label": str(ds), + "value": {"datasource_id": ds.id, "datasource_type": ds.type}, + } + for ds in datasources + ] + return self.response(200, count=len(result), result=result) diff --git a/superset/charts/commands/__init__.py b/superset/charts/commands/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/charts/commands/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/charts/commands/bulk_delete.py b/superset/charts/commands/bulk_delete.py new file mode 100644 index 000000000000..de1af113d008 --- /dev/null +++ b/superset/charts/commands/bulk_delete.py @@ -0,0 +1,61 @@ +# 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 logging +from typing import List, Optional + +from flask_appbuilder.security.sqla.models import User + +from superset.charts.commands.exceptions import ( + ChartBulkDeleteFailedError, + ChartForbiddenError, + ChartNotFoundError, +) +from superset.charts.dao import ChartDAO +from superset.commands.base import BaseCommand +from superset.commands.exceptions import DeleteFailedError +from superset.exceptions import SupersetSecurityException +from superset.models.slice import Slice +from superset.views.base import check_ownership + +logger = logging.getLogger(__name__) + + +class BulkDeleteChartCommand(BaseCommand): + def __init__(self, user: User, model_ids: List[int]): + self._actor = user + self._model_ids = model_ids + self._models: Optional[List[Slice]] = None + + def run(self) -> None: + self.validate() + try: + ChartDAO.bulk_delete(self._models) + except DeleteFailedError as ex: + logger.exception(ex.exception) + raise ChartBulkDeleteFailedError() + + def validate(self) -> None: + # Validate/populate model exists + self._models = ChartDAO.find_by_ids(self._model_ids) + if not self._models or len(self._models) != len(self._model_ids): + raise ChartNotFoundError() + # Check ownership + for model in self._models: + try: + check_ownership(model) + except SupersetSecurityException: + raise ChartForbiddenError() diff --git a/superset/charts/commands/create.py b/superset/charts/commands/create.py new file mode 100644 index 000000000000..8e7dcb7e11a6 --- /dev/null +++ b/superset/charts/commands/create.py @@ -0,0 +1,80 @@ +# 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 logging +from typing import Dict, List, Optional + +from flask_appbuilder.models.sqla import Model +from flask_appbuilder.security.sqla.models import User +from marshmallow import ValidationError + +from superset.charts.commands.exceptions import ( + ChartCreateFailedError, + ChartInvalidError, + DashboardsNotFoundValidationError, +) +from superset.charts.dao import ChartDAO +from superset.commands.base import BaseCommand +from superset.commands.utils import get_datasource_by_id, populate_owners +from superset.dao.exceptions import DAOCreateFailedError +from superset.dashboards.dao import DashboardDAO + +logger = logging.getLogger(__name__) + + +class CreateChartCommand(BaseCommand): + def __init__(self, user: User, data: Dict): + self._actor = user + self._properties = data.copy() + + def run(self) -> Model: + self.validate() + try: + chart = ChartDAO.create(self._properties) + except DAOCreateFailedError as ex: + logger.exception(ex.exception) + raise ChartCreateFailedError() + return chart + + def validate(self) -> None: + exceptions = list() + datasource_type = self._properties["datasource_type"] + datasource_id = self._properties["datasource_id"] + dashboard_ids = self._properties.get("dashboards", []) + owner_ids: Optional[List[int]] = self._properties.get("owners") + + # Validate/Populate datasource + try: + datasource = get_datasource_by_id(datasource_id, datasource_type) + self._properties["datasource_name"] = datasource.name + except ValidationError as ex: + exceptions.append(ex) + + # Validate/Populate dashboards + dashboards = DashboardDAO.find_by_ids(dashboard_ids) + if len(dashboards) != len(dashboard_ids): + exceptions.append(DashboardsNotFoundValidationError()) + self._properties["dashboards"] = dashboards + + try: + owners = populate_owners(self._actor, owner_ids) + self._properties["owners"] = owners + except ValidationError as ex: + exceptions.append(ex) + if exceptions: + exception = ChartInvalidError() + exception.add_list(exceptions) + raise exception diff --git a/superset/charts/commands/delete.py b/superset/charts/commands/delete.py new file mode 100644 index 000000000000..3feb3dbc09ff --- /dev/null +++ b/superset/charts/commands/delete.py @@ -0,0 +1,62 @@ +# 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 logging +from typing import Optional + +from flask_appbuilder.models.sqla import Model +from flask_appbuilder.security.sqla.models import User + +from superset.charts.commands.exceptions import ( + ChartDeleteFailedError, + ChartForbiddenError, + ChartNotFoundError, +) +from superset.charts.dao import ChartDAO +from superset.commands.base import BaseCommand +from superset.dao.exceptions import DAODeleteFailedError +from superset.exceptions import SupersetSecurityException +from superset.models.slice import Slice +from superset.views.base import check_ownership + +logger = logging.getLogger(__name__) + + +class DeleteChartCommand(BaseCommand): + def __init__(self, user: User, model_id: int): + self._actor = user + self._model_id = model_id + self._model: Optional[Slice] = None + + def run(self) -> Model: + self.validate() + try: + chart = ChartDAO.delete(self._model) + except DAODeleteFailedError as ex: + logger.exception(ex.exception) + raise ChartDeleteFailedError() + return chart + + def validate(self) -> None: + # Validate/populate model exists + self._model = ChartDAO.find_by_id(self._model_id) + if not self._model: + raise ChartNotFoundError() + # Check ownership + try: + check_ownership(self._model) + except SupersetSecurityException: + raise ChartForbiddenError() diff --git a/superset/charts/commands/exceptions.py b/superset/charts/commands/exceptions.py new file mode 100644 index 000000000000..2308d62a7721 --- /dev/null +++ b/superset/charts/commands/exceptions.py @@ -0,0 +1,85 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from flask_babel import lazy_gettext as _ +from marshmallow.validate import ValidationError + +from superset.commands.exceptions import ( + CommandException, + CommandInvalidError, + CreateFailedError, + DeleteFailedError, + ForbiddenError, + UpdateFailedError, +) + + +class DatabaseNotFoundValidationError(ValidationError): + """ + Marshmallow validation error for database does not exist + """ + + def __init__(self) -> None: + super().__init__(_("Database does not exist"), field_names=["database"]) + + +class DashboardsNotFoundValidationError(ValidationError): + """ + Marshmallow validation error for dashboards don't exist + """ + + def __init__(self) -> None: + super().__init__(_("Dashboards do not exist"), field_names=["dashboards"]) + + +class DatasourceTypeUpdateRequiredValidationError(ValidationError): + """ + Marshmallow validation error for dashboards don't exist + """ + + def __init__(self) -> None: + super().__init__( + _("Datasource type is required when datasource_id is given"), + field_names=["datasource_type"], + ) + + +class ChartNotFoundError(CommandException): + message = "Chart not found." + + +class ChartInvalidError(CommandInvalidError): + message = _("Chart parameters are invalid.") + + +class ChartCreateFailedError(CreateFailedError): + message = _("Chart could not be created.") + + +class ChartUpdateFailedError(UpdateFailedError): + message = _("Chart could not be updated.") + + +class ChartDeleteFailedError(DeleteFailedError): + message = _("Chart could not be deleted.") + + +class ChartForbiddenError(ForbiddenError): + message = _("Changing this chart is forbidden") + + +class ChartBulkDeleteFailedError(CreateFailedError): + message = _("Charts could not be deleted.") diff --git a/superset/charts/commands/update.py b/superset/charts/commands/update.py new file mode 100644 index 000000000000..21c236ca9621 --- /dev/null +++ b/superset/charts/commands/update.py @@ -0,0 +1,105 @@ +# 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 logging +from typing import Dict, List, Optional + +from flask_appbuilder.models.sqla import Model +from flask_appbuilder.security.sqla.models import User +from marshmallow import ValidationError + +from superset.charts.commands.exceptions import ( + ChartForbiddenError, + ChartInvalidError, + ChartNotFoundError, + ChartUpdateFailedError, + DashboardsNotFoundValidationError, + DatasourceTypeUpdateRequiredValidationError, +) +from superset.charts.dao import ChartDAO +from superset.commands.base import BaseCommand +from superset.commands.utils import get_datasource_by_id, populate_owners +from superset.dao.exceptions import DAOUpdateFailedError +from superset.dashboards.dao import DashboardDAO +from superset.exceptions import SupersetSecurityException +from superset.models.slice import Slice +from superset.views.base import check_ownership + +logger = logging.getLogger(__name__) + + +class UpdateChartCommand(BaseCommand): + def __init__(self, user: User, model_id: int, data: Dict): + self._actor = user + self._model_id = model_id + self._properties = data.copy() + self._model: Optional[Slice] = None + + def run(self) -> Model: + self.validate() + try: + chart = ChartDAO.update(self._model, self._properties) + except DAOUpdateFailedError as ex: + logger.exception(ex.exception) + raise ChartUpdateFailedError() + return chart + + def validate(self) -> None: + exceptions = list() + dashboard_ids = self._properties.get("dashboards", []) + owner_ids: Optional[List[int]] = self._properties.get("owners") + + # Validate if datasource_id is provided datasource_type is required + datasource_id = self._properties.get("datasource_id") + if datasource_id is not None: + datasource_type = self._properties.get("datasource_type", "") + if not datasource_type: + exceptions.append(DatasourceTypeUpdateRequiredValidationError()) + + # Validate/populate model exists + self._model = ChartDAO.find_by_id(self._model_id) + if not self._model: + raise ChartNotFoundError() + # Check ownership + try: + check_ownership(self._model) + except SupersetSecurityException: + raise ChartForbiddenError() + + # Validate/Populate datasource + if datasource_id is not None: + try: + datasource = get_datasource_by_id(datasource_id, datasource_type) + self._properties["datasource_name"] = datasource.name + except ValidationError as ex: + exceptions.append(ex) + + # Validate/Populate dashboards + dashboards = DashboardDAO.find_by_ids(dashboard_ids) + if len(dashboards) != len(dashboard_ids): + exceptions.append(DashboardsNotFoundValidationError()) + self._properties["dashboards"] = dashboards + + # Validate/Populate owner + try: + owners = populate_owners(self._actor, owner_ids) + self._properties["owners"] = owners + except ValidationError as ex: + exceptions.append(ex) + if exceptions: + exception = ChartInvalidError() + exception.add_list(exceptions) + raise exception diff --git a/superset/charts/dao.py b/superset/charts/dao.py new file mode 100644 index 000000000000..912f33c8e25f --- /dev/null +++ b/superset/charts/dao.py @@ -0,0 +1,62 @@ +# 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 logging +from typing import List, Optional, TYPE_CHECKING + +from sqlalchemy.exc import SQLAlchemyError + +from superset.charts.filters import ChartFilter +from superset.connectors.connector_registry import ConnectorRegistry +from superset.dao.base import BaseDAO +from superset.extensions import db +from superset.models.slice import Slice + +if TYPE_CHECKING: + # pylint: disable=unused-import + from superset.connectors.base.models import BaseDatasource + +logger = logging.getLogger(__name__) + + +class ChartDAO(BaseDAO): + model_cls = Slice + base_filter = ChartFilter + + @staticmethod + def bulk_delete(models: Optional[List[Slice]], commit: bool = True) -> None: + item_ids = [model.id for model in models] if models else [] + # bulk delete, first delete related data + if models: + for model in models: + model.owners = [] + model.dashboards = [] + db.session.merge(model) + # bulk delete itself + try: + db.session.query(Slice).filter(Slice.id.in_(item_ids)).delete( + synchronize_session="fetch" + ) + if commit: + db.session.commit() + except SQLAlchemyError as ex: + if commit: + db.session.rollback() + raise ex + + @staticmethod + def fetch_all_datasources() -> List["BaseDatasource"]: + return ConnectorRegistry.get_all_datasources(db.session) diff --git a/superset/charts/filters.py b/superset/charts/filters.py new file mode 100644 index 000000000000..94ae2ad1747e --- /dev/null +++ b/superset/charts/filters.py @@ -0,0 +1,54 @@ +# 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. +from typing import Any + +from flask_babel import lazy_gettext as _ +from sqlalchemy import or_ +from sqlalchemy.orm.query import Query + +from superset import security_manager +from superset.models.slice import Slice +from superset.views.base import BaseFilter + + +class ChartNameOrDescriptionFilter( + BaseFilter +): # pylint: disable=too-few-public-methods + name = _("Name or Description") + arg_name = "name_or_description" + + def apply(self, query: Query, value: Any) -> Query: + if not value: + return query + ilike_value = f"%{value}%" + return query.filter( + or_( + Slice.slice_name.ilike(ilike_value), + Slice.description.ilike(ilike_value), + ) + ) + + +class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods + def apply(self, query: Query, value: Any) -> Query: + if security_manager.all_datasource_access(): + return query + perms = security_manager.user_view_menu_names("datasource_access") + schema_perms = security_manager.user_view_menu_names("schema_access") + return query.filter( + or_(self.model.perm.in_(perms), self.model.schema_perm.in_(schema_perms)) + ) diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py new file mode 100644 index 000000000000..49d0480224e1 --- /dev/null +++ b/superset/charts/schemas.py @@ -0,0 +1,603 @@ +# 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. +from typing import Any, Dict, Union + +from marshmallow import fields, post_load, Schema, ValidationError +from marshmallow.validate import Length + +from superset.common.query_context import QueryContext +from superset.exceptions import SupersetException +from superset.utils import core as utils + +get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} +thumbnail_query_schema = { + "type": "object", + "properties": {"force": {"type": "boolean"}}, +} + + +def validate_json(value: Union[bytes, bytearray, str]) -> None: + try: + utils.validate_json(value) + except SupersetException: + raise ValidationError("JSON not valid") + + +class ChartPostSchema(Schema): + slice_name = fields.String(required=True, validate=Length(1, 250)) + description = fields.String(allow_none=True) + viz_type = fields.String(allow_none=True, validate=Length(0, 250)) + owners = fields.List(fields.Integer()) + params = fields.String(allow_none=True, validate=validate_json) + cache_timeout = fields.Integer(allow_none=True) + datasource_id = fields.Integer(required=True) + datasource_type = fields.String(required=True) + datasource_name = fields.String(allow_none=True) + dashboards = fields.List(fields.Integer()) + + +class ChartPutSchema(Schema): + slice_name = fields.String(allow_none=True, validate=Length(0, 250)) + description = fields.String(allow_none=True) + viz_type = fields.String(allow_none=True, validate=Length(0, 250)) + owners = fields.List(fields.Integer()) + params = fields.String(allow_none=True) + cache_timeout = fields.Integer(allow_none=True) + datasource_id = fields.Integer(allow_none=True) + datasource_type = fields.String(allow_none=True) + dashboards = fields.List(fields.Integer()) + + +class ChartDataColumnSchema(Schema): + column_name = fields.String( + description="The name of the target column", example="mycol", + ) + type = fields.String(description="Type of target column", example="BIGINT",) + + +class ChartDataAdhocMetricSchema(Schema): + """ + Ad-hoc metrics are used to define metrics outside the datasource. + """ + + expressionType = fields.String( + description="Simple or SQL metric", + required=True, + enum=["SIMPLE", "SQL"], + example="SQL", + ) + aggregate = fields.String( + description="Aggregation operator. Only required for simple expression types.", + required=False, + enum=["AVG", "COUNT", "COUNT_DISTINCT", "MAX", "MIN", "SUM"], + ) + column = fields.Nested(ChartDataColumnSchema) + sqlExpression = fields.String( + description="The metric as defined by a SQL aggregate expression. " + "Only required for SQL expression type.", + required=False, + example="SUM(weight * observations) / SUM(weight)", + ) + label = fields.String( + description="Label for the metric. Is automatically generated unless " + "hasCustomLabel is true, in which case label must be defined.", + required=False, + example="Weighted observations", + ) + hasCustomLabel = fields.Boolean( + description="When false, the label will be automatically generated based on " + "the aggregate expression. When true, a custom label has to be " + "specified.", + required=False, + example=True, + ) + optionName = fields.String( + description="Unique identifier. Can be any string value, as long as all " + "metrics have a unique identifier. If undefined, a random name " + "will be generated.", + required=False, + example="metric_aec60732-fac0-4b17-b736-93f1a5c93e30", + ) + + +class ChartDataAggregateConfigField(fields.Dict): + def __init__(self) -> None: + super().__init__( + description="The keys are the name of the aggregate column to be created, " + "and the values specify the details of how to apply the " + "aggregation. If an operator requires additional options, " + "these can be passed here to be unpacked in the operator call. The " + "following numpy operators are supported: average, argmin, argmax, cumsum, " + "cumprod, max, mean, median, nansum, nanmin, nanmax, nanmean, nanmedian, " + "min, percentile, prod, product, std, sum, var. Any options required by " + "the operator can be passed to the `options` object.\n" + "\n" + "In the example, a new column `first_quantile` is created based on values " + "in the column `my_col` using the `percentile` operator with " + "the `q=0.25` parameter.", + example={ + "first_quantile": { + "operator": "percentile", + "column": "my_col", + "options": {"q": 0.25}, + } + }, + ) + + +class ChartDataPostProcessingOperationOptionsSchema(Schema): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + +class ChartDataAggregateOptionsSchema(ChartDataPostProcessingOperationOptionsSchema): + """ + Aggregate operation config. + """ + + groupby = ( + fields.List( + fields.String( + allow_none=False, description="Columns by which to group by", + ), + minLength=1, + required=True, + ), + ) + aggregates = ChartDataAggregateConfigField() + + +class ChartDataRollingOptionsSchema(ChartDataPostProcessingOperationOptionsSchema): + """ + Rolling operation config. + """ + + columns = ( + fields.Dict( + description="columns on which to perform rolling, mapping source column to " + "target column. For instance, `{'y': 'y'}` will replace the " + "column `y` with the rolling value in `y`, while `{'y': 'y2'}` " + "will add a column `y2` based on rolling values calculated " + "from `y`, leaving the original column `y` unchanged.", + example={"weekly_rolling_sales": "sales"}, + ), + ) + rolling_type = fields.String( + description="Type of rolling window. Any numpy function will work.", + enum=[ + "average", + "argmin", + "argmax", + "cumsum", + "cumprod", + "max", + "mean", + "median", + "nansum", + "nanmin", + "nanmax", + "nanmean", + "nanmedian", + "min", + "percentile", + "prod", + "product", + "std", + "sum", + "var", + ], + required=True, + example="percentile", + ) + window = fields.Integer( + description="Size of the rolling window in days.", required=True, example=7, + ) + rolling_type_options = fields.Dict( + desctiption="Optional options to pass to rolling method. Needed for " + "e.g. quantile operation.", + required=False, + example={}, + ) + center = fields.Boolean( + description="Should the label be at the center of the window. Default: `false`", + required=False, + example=False, + ) + win_type = fields.String( + description="Type of window function. See " + "[SciPy window functions](https://docs.scipy.org/doc/scipy/reference" + "/signal.windows.html#module-scipy.signal.windows) " + "for more details. Some window functions require passing " + "additional parameters to `rolling_type_options`. For instance, " + "to use `gaussian`, the parameter `std` needs to be provided.", + required=False, + enum=[ + "boxcar", + "triang", + "blackman", + "hamming", + "bartlett", + "parzen", + "bohman", + "blackmanharris", + "nuttall", + "barthann", + "kaiser", + "gaussian", + "general_gaussian", + "slepian", + "exponential", + ], + ) + min_periods = fields.Integer( + description="The minimum amount of periods required for a row to be included " + "in the result set.", + required=False, + example=7, + ) + + +class ChartDataSelectOptionsSchema(ChartDataPostProcessingOperationOptionsSchema): + """ + Sort operation config. + """ + + columns = fields.List( + fields.String(), + description="Columns which to select from the input data, in the desired " + "order. If columns are renamed, the old column name should be " + "referenced here.", + example=["country", "gender", "age"], + ) + rename = fields.List( + fields.Dict(), + description="columns which to rename, mapping source column to target column. " + "For instance, `{'y': 'y2'}` will rename the column `y` to `y2`.", + example=[{"age": "average_age"}], + ) + + +class ChartDataSortOptionsSchema(ChartDataPostProcessingOperationOptionsSchema): + """ + Sort operation config. + """ + + columns = fields.Dict( + description="columns by by which to sort. The key specifies the column name, " + "value specifies if sorting in ascending order.", + example={"country": True, "gender": False}, + required=True, + ) + aggregates = ChartDataAggregateConfigField() + + +class ChartDataPivotOptionsSchema(ChartDataPostProcessingOperationOptionsSchema): + """ + Pivot operation config. + """ + + index = ( + fields.List( + fields.String( + allow_none=False, + description="Columns to group by on the table index (=rows)", + ), + minLength=1, + required=True, + ), + ) + columns = fields.List( + fields.String( + allow_none=False, description="Columns to group by on the table columns", + ), + minLength=1, + required=True, + ) + metric_fill_value = fields.Number( + required=False, + description="Value to replace missing values with in aggregate calculations.", + ) + column_fill_value = fields.String( + required=False, description="Value to replace missing pivot columns names with." + ) + drop_missing_columns = fields.Boolean( + description="Do not include columns whose entries are all missing " + "(default: `true`).", + required=False, + ) + marginal_distributions = fields.Boolean( + description="Add totals for row/column. (default: `false`)", required=False, + ) + marginal_distribution_name = fields.String( + description="Name of marginal distribution row/column. (default: `All`)", + required=False, + ) + aggregates = ChartDataAggregateConfigField() + + +class ChartDataPostProcessingOperationSchema(Schema): + operation = fields.String( + description="Post processing operation type", + required=True, + enum=["aggregate", "pivot", "rolling", "select", "sort"], + example="aggregate", + ) + options = fields.Nested( + ChartDataPostProcessingOperationOptionsSchema, + description="Options specifying how to perform the operation. Please refer " + "to the respective post processing operation option schemas. " + "For example, `ChartDataPostProcessingOperationOptions` specifies " + "the required options for the pivot operation.", + example={ + "groupby": ["country", "gender"], + "aggregates": { + "age_q1": { + "operator": "percentile", + "column": "age", + "options": {"q": 0.25}, + }, + "age_mean": {"operator": "mean", "column": "age",}, + }, + }, + ) + + +class ChartDataFilterSchema(Schema): + col = fields.String( + description="The column to filter.", required=True, example="country" + ) + op = fields.String( # pylint: disable=invalid-name + description="The comparison operator.", + enum=[filter_op.value for filter_op in utils.FilterOperationType], + required=True, + example="IN", + ) + val = fields.Raw( + description="The value or values to compare against. Can be a string, " + "integer, decimal or list, depending on the operator.", + example=["China", "France", "Japan"], + ) + + +class ChartDataExtrasSchema(Schema): + + time_range_endpoints = fields.List( + fields.String(enum=["INCLUSIVE", "EXCLUSIVE"]), + description="A list with two values, stating if start/end should be " + "inclusive/exclusive.", + required=False, + ) + relative_start = fields.String( + description="Start time for relative time deltas. " + 'Default: `config["DEFAULT_RELATIVE_START_TIME"]`', + enum=["today", "now"], + required=False, + ) + relative_end = fields.String( + description="End time for relative time deltas. " + 'Default: `config["DEFAULT_RELATIVE_START_TIME"]`', + enum=["today", "now"], + required=False, + ) + where = fields.String( + description="WHERE clause to be added to queries using AND operator.", + required=False, + ) + having = fields.String( + description="HAVING clause to be added to aggregate queries using " + "AND operator.", + required=False, + ) + having_druid = fields.String( + description="HAVING filters to be added to legacy Druid datasource queries.", + required=False, + ) + + +class ChartDataQueryObjectSchema(Schema): + filters = fields.List(fields.Nested(ChartDataFilterSchema), required=False) + granularity = fields.String( + description="To what level of granularity should the temporal column be " + "aggregated. Supports " + "[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) " + "durations.", + enum=[ + "PT1S", + "PT1M", + "PT5M", + "PT10M", + "PT15M", + "PT0.5H", + "PT1H", + "P1D", + "P1W", + "P1M", + "P0.25Y", + "P1Y", + ], + required=False, + example="P1D", + ) + groupby = fields.List( + fields.String(description="Columns by which to group the query.",), + ) + metrics = fields.List( + fields.Raw(), + description="Aggregate expressions. Metrics can be passed as both " + "references to datasource metrics (strings), or ad-hoc metrics" + "which are defined only within the query object. See " + "`ChartDataAdhocMetricSchema` for the structure of ad-hoc metrics.", + ) + post_processing = fields.List( + fields.Nested(ChartDataPostProcessingOperationSchema), + description="Post processing operations to be applied to the result set. " + "Operations are applied to the result set in sequential order.", + required=False, + ) + time_range = fields.String( + description="A time rage, either expressed as a colon separated string " + "`since : until`. Valid formats for `since` and `until` are: \n" + "- ISO 8601\n" + "- X days/years/hours/day/year/weeks\n" + "- X days/years/hours/day/year/weeks ago\n" + "- X days/years/hours/day/year/weeks from now\n" + "\n" + "Additionally, the following freeform can be used:\n" + "\n" + "- Last day\n" + "- Last week\n" + "- Last month\n" + "- Last quarter\n" + "- Last year\n" + "- No filter\n" + "- Last X seconds/minutes/hours/days/weeks/months/years\n" + "- Next X seconds/minutes/hours/days/weeks/months/years\n", + required=False, + example="Last week", + ) + time_shift = fields.String( + description="A human-readable date/time string. " + "Please refer to [parsdatetime](https://github.com/bear/parsedatetime) " + "documentation for details on valid values.", + required=False, + ) + is_timeseries = fields.Boolean( + description="Is the `query_object` a timeseries.", required=False + ) + timeseries_limit = fields.Integer( + description="Maximum row count for timeseries queries. Default: `0`", + required=False, + ) + row_limit = fields.Integer( + description='Maximum row count. Default: `config["ROW_LIMIT"]`', required=False, + ) + order_desc = fields.Boolean( + description="Reverse order. Default: `false`", required=False + ) + extras = fields.Dict(description=" Default: `{}`", required=False) + columns = fields.List(fields.String(), description="", required=False,) + orderby = fields.List( + fields.List(fields.Raw()), + description="Expects a list of lists where the first element is the column " + "name which to sort by, and the second element is a boolean ", + required=False, + example=[["my_col_1", False], ["my_col_2", True]], + ) + where = fields.String( + description="WHERE clause to be added to queries using AND operator." + "This field is deprecated, and should be passed to `extras`.", + required=False, + deprecated=True, + ) + having = fields.String( + description="HAVING clause to be added to aggregate queries using " + "AND operator. This field is deprecated, and should be passed " + "to `extras`.", + required=False, + deprecated=True, + ) + having_filters = fields.List( + fields.Dict(), + description="HAVING filters to be added to legacy Druid datasource queries. " + "This field is deprecated, and should be passed to `extras` " + "as `filters_druid`.", + required=False, + deprecated=True, + ) + + +class ChartDataDatasourceSchema(Schema): + description = "Chart datasource" + id = fields.Integer(description="Datasource id", required=True,) + type = fields.String(description="Datasource type", enum=["druid", "sql"]) + + +class ChartDataQueryContextSchema(Schema): + datasource = fields.Nested(ChartDataDatasourceSchema) + queries = fields.List(fields.Nested(ChartDataQueryObjectSchema)) + + # pylint: disable=no-self-use + @post_load + def make_query_context(self, data: Dict[str, Any]) -> QueryContext: + query_context = QueryContext(**data) + return query_context + + # pylint: enable=no-self-use + + +class ChartDataResponseResult(Schema): + cache_key = fields.String( + description="Unique cache key for query object", required=True, allow_none=True, + ) + cached_dttm = fields.String( + description="Cache timestamp", required=True, allow_none=True, + ) + cache_timeout = fields.Integer( + description="Cache timeout in following order: custom timeout, datasource " + "timeout, default config timeout.", + required=True, + allow_none=True, + ) + error = fields.String(description="Error", allow_none=True,) + is_cached = fields.Boolean( + description="Is the result cached", required=True, allow_none=None, + ) + query = fields.String( + description="The executed query statement", required=True, allow_none=False, + ) + status = fields.String( + description="Status of the query", + enum=[ + "stopped", + "failed", + "pending", + "running", + "scheduled", + "success", + "timed_out", + ], + allow_none=False, + ) + stacktrace = fields.String( + desciption="Stacktrace if there was an error", allow_none=True, + ) + rowcount = fields.Integer( + description="Amount of rows in result set", allow_none=False, + ) + data = fields.List(fields.Dict(), description="A list with results") + + +class ChartDataResponseSchema(Schema): + result = fields.List( + fields.Nested(ChartDataResponseResult), + description="A list of results for each corresponding query in the request.", + ) + + +CHART_DATA_SCHEMAS = ( + ChartDataQueryContextSchema, + ChartDataResponseSchema, + # TODO: These should optimally be included in the QueryContext schema as an `anyOf` + # in ChartDataPostPricessingOperation.options, but since `anyOf` is not + # by Marshmallow<3, this is not currently possible. + ChartDataAdhocMetricSchema, + ChartDataAggregateOptionsSchema, + ChartDataPivotOptionsSchema, + ChartDataRollingOptionsSchema, + ChartDataSelectOptionsSchema, + ChartDataSortOptionsSchema, +) diff --git a/superset/cli.py b/superset/cli.py index c6cdc45c1158..3bf10d7592be 100755 --- a/superset/cli.py +++ b/superset/cli.py @@ -19,6 +19,7 @@ from datetime import datetime from subprocess import Popen from sys import stdout +from typing import Type, Union import click import yaml @@ -197,9 +198,9 @@ def refresh_druid(datasource, merge): for cluster in session.query(DruidCluster).all(): try: cluster.refresh_datasources(datasource_name=datasource, merge_flag=merge) - except Exception as e: # pylint: disable=broad-except - print("Error while processing cluster '{}'\n{}".format(cluster, str(e))) - logger.exception(e) + except Exception as ex: # pylint: disable=broad-except + print("Error while processing cluster '{}'\n{}".format(cluster, str(ex))) + logger.exception(ex) cluster.metadata_last_refreshed = datetime.now() print("Refreshed metadata from cluster " "[" + cluster.cluster_name + "]") session.commit() @@ -245,9 +246,9 @@ def import_dashboards(path, recursive, username): try: with file_.open() as data_stream: dashboard_import_export.import_dashboards(db.session, data_stream) - except Exception as e: # pylint: disable=broad-except + except Exception as ex: # pylint: disable=broad-except logger.error("Error when importing dashboard from file %s", file_) - logger.error(e) + logger.error(ex) @superset.command() @@ -317,9 +318,9 @@ def import_datasources(path, sync, recursive): dict_import_export.import_from_dict( db.session, yaml.safe_load(data_stream), sync=sync_array ) - except Exception as e: # pylint: disable=broad-except + except Exception as ex: # pylint: disable=broad-except logger.error("Error when importing datasources from file %s", file_) - logger.error(e) + logger.error(ex) @superset.command() @@ -397,8 +398,8 @@ def update_datasources_cache(): database.get_all_view_names_in_database( force=True, cache=True, cache_timeout=24 * 60 * 60 ) - except Exception as e: # pylint: disable=broad-except - print("{}".format(str(e))) + except Exception as ex: # pylint: disable=broad-except + print("{}".format(str(ex))) @superset.command() @@ -454,6 +455,78 @@ def flower(port, address): Popen(cmd, shell=True).wait() +@superset.command() +@with_appcontext +@click.option( + "--asynchronous", + "-a", + is_flag=True, + default=False, + help="Trigger commands to run remotely on a worker", +) +@click.option( + "--dashboards_only", + "-d", + is_flag=True, + default=False, + help="Only process dashboards", +) +@click.option( + "--charts_only", "-c", is_flag=True, default=False, help="Only process charts" +) +@click.option( + "--force", + "-f", + is_flag=True, + default=False, + help="Force refresh, even if previously cached", +) +@click.option("--model_id", "-i", multiple=True) +def compute_thumbnails( + asynchronous: bool, + dashboards_only: bool, + charts_only: bool, + force: bool, + model_id: int, +): + """Compute thumbnails""" + from superset.models.dashboard import Dashboard + from superset.models.slice import Slice + from superset.tasks.thumbnails import ( + cache_chart_thumbnail, + cache_dashboard_thumbnail, + ) + + def compute_generic_thumbnail( + friendly_type: str, + model_cls: Union[Type[Dashboard], Type[Slice]], + model_id: int, + compute_func, + ): + query = db.session.query(model_cls) + if model_id: + query = query.filter(model_cls.id.in_(model_id)) + dashboards = query.all() + count = len(dashboards) + for i, model in enumerate(dashboards): + if asynchronous: + func = compute_func.delay + action = "Triggering" + else: + func = compute_func + action = "Processing" + msg = f'{action} {friendly_type} "{model}" ({i+1}/{count})' + click.secho(msg, fg="green") + func(model.id, force=force) + + if not charts_only: + compute_generic_thumbnail( + "dashboard", Dashboard, model_id, cache_dashboard_thumbnail + ) + if not dashboards_only: + compute_generic_thumbnail("chart", Slice, model_id, cache_chart_thumbnail) + + @superset.command() @with_appcontext def load_test_users(): diff --git a/superset/commands/base.py b/superset/commands/base.py index 44f46eb74221..998a7568597b 100644 --- a/superset/commands/base.py +++ b/superset/commands/base.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. from abc import ABC, abstractmethod +from typing import Any class BaseCommand(ABC): @@ -23,7 +24,7 @@ class BaseCommand(ABC): """ @abstractmethod - def run(self): + def run(self) -> Any: """ Run executes the command. Can raise command exceptions :raises: CommandException diff --git a/superset/commands/exceptions.py b/superset/commands/exceptions.py index 61a18ebdc33f..cf67ea922715 100644 --- a/superset/commands/exceptions.py +++ b/superset/commands/exceptions.py @@ -14,7 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from typing import List +from typing import Any, Dict, List from flask_babel import lazy_gettext as _ from marshmallow import ValidationError @@ -25,7 +25,10 @@ class CommandException(SupersetException): """ Common base class for Command exceptions. """ - pass + def __repr__(self) -> str: + if self._exception: + return repr(self._exception) + return repr(self) class CommandInvalidError(CommandException): @@ -33,18 +36,18 @@ class CommandInvalidError(CommandException): status = 422 - def __init__(self, message=""): - self._invalid_exceptions = list() + def __init__(self, message: str = "") -> None: + self._invalid_exceptions: List[ValidationError] = [] super().__init__(self.message) - def add(self, exception: ValidationError): + def add(self, exception: ValidationError) -> None: self._invalid_exceptions.append(exception) - def add_list(self, exceptions: List[ValidationError]): + def add_list(self, exceptions: List[ValidationError]) -> None: self._invalid_exceptions.extend(exceptions) - def normalized_messages(self): - errors = {} + def normalized_messages(self) -> Dict[Any, Any]: + errors: Dict[Any, Any] = {} for exception in self._invalid_exceptions: errors.update(exception.normalized_messages()) return errors @@ -73,5 +76,12 @@ class ForbiddenError(CommandException): class OwnersNotFoundValidationError(ValidationError): status = 422 - def __init__(self): + def __init__(self) -> None: super().__init__(_("Owners are invalid"), field_names=["owners"]) + + +class DatasourceNotFoundValidationError(ValidationError): + status = 404 + + def __init__(self) -> None: + super().__init__(_("Datasource does not exist"), field_names=["datasource_id"]) diff --git a/superset/commands/utils.py b/superset/commands/utils.py index 9865549cfb32..c0bd8b707055 100644 --- a/superset/commands/utils.py +++ b/superset/commands/utils.py @@ -17,9 +17,15 @@ from typing import List, Optional from flask_appbuilder.security.sqla.models import User +from sqlalchemy.orm.exc import NoResultFound -from superset.commands.exceptions import OwnersNotFoundValidationError -from superset.extensions import security_manager +from superset.commands.exceptions import ( + DatasourceNotFoundValidationError, + OwnersNotFoundValidationError, +) +from superset.connectors.base.models import BaseDatasource +from superset.connectors.connector_registry import ConnectorRegistry +from superset.extensions import db, security_manager def populate_owners(user: User, owners_ids: Optional[List[int]] = None) -> List[User]: @@ -40,3 +46,12 @@ def populate_owners(user: User, owners_ids: Optional[List[int]] = None) -> List[ raise OwnersNotFoundValidationError() owners.append(owner) return owners + + +def get_datasource_by_id(datasource_id: int, datasource_type: str) -> BaseDatasource: + try: + return ConnectorRegistry.get_datasource( + datasource_type, datasource_id, db.session + ) + except (NoResultFound, KeyError): + raise DatasourceNotFoundValidationError() diff --git a/superset/common/query_context.py b/superset/common/query_context.py index 40df7c95c5fd..656988508db8 100644 --- a/superset/common/query_context.py +++ b/superset/common/query_context.py @@ -51,7 +51,7 @@ class QueryContext: custom_cache_timeout: Optional[int] # TODO: Type datasource and query_object dictionary with TypedDict when it becomes - # a vanilla python type https://github.com/python/mypy/issues/5288 + # a vanilla python type https://github.com/python/mypy/issues/5288 def __init__( self, datasource: Dict[str, Any], @@ -70,8 +70,8 @@ def get_query_result(self, query_object: QueryObject) -> Dict[str, Any]: """Returns a pandas dataframe based on the query object""" # Here, we assume that all the queries will use the same datasource, which is - # is a valid assumption for current setting. In a long term, we may or maynot - # support multiple queries from different data source. + # a valid assumption for current setting. In the long term, we may + # support multiple queries from different data sources. timestamp_format = None if self.datasource.type == "table": @@ -105,6 +105,9 @@ def get_query_result(self, query_object: QueryObject) -> Dict[str, Any]: self.df_metrics_to_num(df, query_object) df.replace([np.inf, -np.inf], np.nan) + + df = query_object.exec_post_processing(df) + return { "query": result.query, "status": result.status, @@ -113,7 +116,7 @@ def get_query_result(self, query_object: QueryObject) -> Dict[str, Any]: } @staticmethod - def df_metrics_to_num( # pylint: disable=invalid-name,no-self-use + def df_metrics_to_num( # pylint: disable=no-self-use df: pd.DataFrame, query_object: QueryObject ) -> None: """Converting metrics to numeric when pandas.read_sql cannot""" @@ -122,9 +125,7 @@ def df_metrics_to_num( # pylint: disable=invalid-name,no-self-use df[col] = pd.to_numeric(df[col], errors="coerce") @staticmethod - def get_data( # pylint: disable=invalid-name,no-self-use - df: pd.DataFrame, - ) -> List[Dict]: + def get_data(df: pd.DataFrame,) -> List[Dict]: # pylint: disable=no-self-use return df.to_dict(orient="records") def get_single_payload(self, query_obj: QueryObject) -> Dict[str, Any]: @@ -157,7 +158,7 @@ def cache_timeout(self) -> int: return self.datasource.database.cache_timeout return config["CACHE_DEFAULT_TIMEOUT"] - def cache_key(self, query_obj: QueryObject, **kwargs) -> Optional[str]: + def cache_key(self, query_obj: QueryObject, **kwargs: Any) -> Optional[str]: extra_cache_keys = self.datasource.get_extra_cache_keys(query_obj.to_dict()) cache_key = ( query_obj.cache_key( @@ -173,7 +174,7 @@ def cache_key(self, query_obj: QueryObject, **kwargs) -> Optional[str]: return cache_key def get_df_payload( # pylint: disable=too-many-locals,too-many-statements - self, query_obj: QueryObject, **kwargs + self, query_obj: QueryObject, **kwargs: Any ) -> Dict[str, Any]: """Handles caching around the df payload retrieval""" cache_key = self.cache_key(query_obj, **kwargs) @@ -197,10 +198,10 @@ def get_df_payload( # pylint: disable=too-many-locals,too-many-statements status = utils.QueryStatus.SUCCESS is_loaded = True stats_logger.incr("loaded_from_cache") - except Exception as e: # pylint: disable=broad-except - logger.exception(e) + except Exception as ex: # pylint: disable=broad-except + logger.exception(ex) logger.error( - "Error reading cache: %s", utils.error_msg_from_exception(e) + "Error reading cache: %s", utils.error_msg_from_exception(ex) ) logger.info("Serving from cache") @@ -213,11 +214,13 @@ def get_df_payload( # pylint: disable=too-many-locals,too-many-statements df = query_result["df"] if status != utils.QueryStatus.FAILED: stats_logger.incr("loaded_from_source") + if not self.force: + stats_logger.incr("loaded_from_source_without_force") is_loaded = True - except Exception as e: # pylint: disable=broad-except - logger.exception(e) + except Exception as ex: # pylint: disable=broad-except + logger.exception(ex) if not error_message: - error_message = "{}".format(e) + error_message = "{}".format(ex) status = utils.QueryStatus.FAILED stacktrace = utils.get_stacktrace() @@ -232,11 +235,11 @@ def get_df_payload( # pylint: disable=too-many-locals,too-many-statements stats_logger.incr("set_cache_key") cache.set(cache_key, cache_binary, timeout=self.cache_timeout) - except Exception as e: # pylint: disable=broad-except + except Exception as ex: # pylint: disable=broad-except # cache.set call can fail if the backend is down or if # the key is too large or whatever other reasons logger.warning("Could not cache key %s", cache_key) - logger.exception(e) + logger.exception(ex) cache.delete(cache_key) return { "cache_key": cache_key, diff --git a/superset/common/query_object.py b/superset/common/query_object.py index e0681be7971d..0a83ef7fbe77 100644 --- a/superset/common/query_object.py +++ b/superset/common/query_object.py @@ -16,17 +16,35 @@ # under the License. # pylint: disable=R import hashlib +import logging from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, NamedTuple, Optional, Union import simplejson as json +from flask_babel import gettext as _ +from pandas import DataFrame -from superset import app -from superset.utils import core as utils +from superset import app, is_feature_enabled +from superset.exceptions import QueryObjectValidationError +from superset.utils import core as utils, pandas_postprocessing from superset.views.utils import get_time_range_endpoints +logger = logging.getLogger(__name__) + # TODO: Type Metrics dictionary with TypedDict when it becomes a vanilla python type -# https://github.com/python/mypy/issues/5288 +# https://github.com/python/mypy/issues/5288 + + +class DeprecatedExtrasField(NamedTuple): + name: str + extras_name: str + + +DEPRECATED_EXTRAS_FIELDS = ( + DeprecatedExtrasField(name="where", extras_name="where"), + DeprecatedExtrasField(name="having", extras_name="having"), + DeprecatedExtrasField(name="having_filters", extras_name="having_druid"), +) class QueryObject: @@ -41,22 +59,23 @@ class QueryObject: is_timeseries: bool time_shift: Optional[timedelta] groupby: List[str] - metrics: List[Union[Dict, str]] + metrics: List[Union[Dict[str, Any], str]] row_limit: int - filter: List[str] + filter: List[Dict[str, Any]] timeseries_limit: int timeseries_limit_metric: Optional[Dict] order_desc: bool extras: Dict columns: List[str] orderby: List[List] + post_processing: List[Dict[str, Any]] def __init__( self, granularity: str, - metrics: List[Union[Dict, str]], + metrics: List[Union[Dict[str, Any], str]], groupby: Optional[List[str]] = None, - filters: Optional[List[str]] = None, + filters: Optional[List[Dict[str, Any]]] = None, time_range: Optional[str] = None, time_shift: Optional[str] = None, is_timeseries: bool = False, @@ -67,22 +86,30 @@ def __init__( extras: Optional[Dict] = None, columns: Optional[List[str]] = None, orderby: Optional[List[List]] = None, - relative_start: str = app.config["DEFAULT_RELATIVE_START_TIME"], - relative_end: str = app.config["DEFAULT_RELATIVE_END_TIME"], + post_processing: Optional[List[Dict[str, Any]]] = None, + **kwargs: Any, ): + extras = extras or {} + is_sip_38 = is_feature_enabled("SIP_38_VIZ_REARCHITECTURE") self.granularity = granularity self.from_dttm, self.to_dttm = utils.get_since_until( - relative_start=relative_start, - relative_end=relative_end, + relative_start=extras.get( + "relative_start", app.config["DEFAULT_RELATIVE_START_TIME"] + ), + relative_end=extras.get( + "relative_end", app.config["DEFAULT_RELATIVE_END_TIME"] + ), time_range=time_range, time_shift=time_shift, ) self.is_timeseries = is_timeseries self.time_range = time_range self.time_shift = utils.parse_human_timedelta(time_shift) - self.groupby = groupby or [] + self.post_processing = post_processing or [] + if not is_sip_38: + self.groupby = groupby or [] - # Temporal solution for backward compatability issue due the new format of + # Temporary solution for backward compatibility issue due the new format of # non-ad-hoc metric which needs to adhere to superset-ui per # https://git.io/Jvm7P. self.metrics = [ @@ -95,21 +122,38 @@ def __init__( self.timeseries_limit = timeseries_limit self.timeseries_limit_metric = timeseries_limit_metric self.order_desc = order_desc - self.extras = extras or {} + self.extras = extras if app.config["SIP_15_ENABLED"] and "time_range_endpoints" not in self.extras: self.extras["time_range_endpoints"] = get_time_range_endpoints(form_data={}) self.columns = columns or [] + if is_sip_38 and groupby: + self.columns += groupby + logger.warning( + f"The field groupby is deprecated. Viz plugins should " + f"pass all selectables via the columns field" + ) + self.orderby = orderby or [] + # move deprecated fields to extras + for field in DEPRECATED_EXTRAS_FIELDS: + if field.name in kwargs: + logger.warning( + f"The field `{field.name} is deprecated, and should be " + f"passed to `extras` via the `{field.extras_name}` property" + ) + value = kwargs[field.name] + if value: + self.extras[field.extras_name] = value + def to_dict(self) -> Dict[str, Any]: query_object_dict = { "granularity": self.granularity, "from_dttm": self.from_dttm, "to_dttm": self.to_dttm, "is_timeseries": self.is_timeseries, - "groupby": self.groupby, "metrics": self.metrics, "row_limit": self.row_limit, "filter": self.filter, @@ -120,9 +164,12 @@ def to_dict(self) -> Dict[str, Any]: "columns": self.columns, "orderby": self.orderby, } + if not is_feature_enabled("SIP_38_VIZ_REARCHITECTURE"): + query_object_dict["groupby"] = self.groupby + return query_object_dict - def cache_key(self, **extra) -> str: + def cache_key(self, **extra: Any) -> str: """ The cache key is made out of the key/values from to_dict(), plus any other key/values in `extra` @@ -138,9 +185,37 @@ def cache_key(self, **extra) -> str: if self.time_range: cache_dict["time_range"] = self.time_range json_data = self.json_dumps(cache_dict, sort_keys=True) + if self.post_processing: + cache_dict["post_processing"] = self.post_processing return hashlib.md5(json_data.encode("utf-8")).hexdigest() def json_dumps(self, obj: Any, sort_keys: bool = False) -> str: return json.dumps( obj, default=utils.json_int_dttm_ser, ignore_nan=True, sort_keys=sort_keys ) + + def exec_post_processing(self, df: DataFrame) -> DataFrame: + """ + Perform post processing operations on DataFrame. + + :param df: DataFrame returned from database model. + :return: new DataFrame to which all post processing operations have been + applied + :raises ChartDataValidationError: If the post processing operation in incorrect + """ + for post_process in self.post_processing: + operation = post_process.get("operation") + if not operation: + raise QueryObjectValidationError( + _("`operation` property of post processing object undefined") + ) + if not hasattr(pandas_postprocessing, operation): + raise QueryObjectValidationError( + _( + "Unsupported post processing operation: %(operation)s", + type=operation, + ) + ) + options = post_process.get("options", {}) + df = getattr(pandas_postprocessing, operation)(df, **options) + return df diff --git a/superset/common/tags.py b/superset/common/tags.py index 657611c602b8..74c882cf92f6 100644 --- a/superset/common/tags.py +++ b/superset/common/tags.py @@ -14,14 +14,15 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. - +from sqlalchemy import Metadata +from sqlalchemy.engine import Engine from sqlalchemy.exc import IntegrityError from sqlalchemy.sql import and_, func, functions, join, literal, select from superset.models.tags import ObjectTypes, TagTypes -def add_types(engine, metadata): +def add_types(engine: Engine, metadata: Metadata) -> None: """ Tag every object according to its type: @@ -163,7 +164,7 @@ def add_types(engine, metadata): engine.execute(query) -def add_owners(engine, metadata): +def add_owners(engine: Engine, metadata: Metadata) -> None: """ Tag every object according to its owner: @@ -319,7 +320,7 @@ def add_owners(engine, metadata): engine.execute(query) -def add_favorites(engine, metadata): +def add_favorites(engine: Engine, metadata: Metadata) -> None: """ Tag every object that was favorited: diff --git a/superset/config.py b/superset/config.py index 8a85d14cd5da..19de94a0561c 100644 --- a/superset/config.py +++ b/superset/config.py @@ -34,6 +34,9 @@ from dateutil import tz from flask_appbuilder.security.manager import AUTH_DB +from superset.jinja_context import ( # pylint: disable=unused-import + BaseTemplateProcessor, +) from superset.stats_logger import DummyStatsLogger from superset.typing import CacheConfig from superset.utils.log import DBEventLogger @@ -282,10 +285,14 @@ def _try_json_readsha(filepath, length): # pylint: disable=unused-argument "ENABLE_EXPLORE_JSON_CSRF_PROTECTION": False, "KV_STORE": False, "PRESTO_EXPAND_DATA": False, - "REDUCE_DASHBOARD_BOOTSTRAP_PAYLOAD": False, + # Exposes API endpoint to compute thumbnails + "THUMBNAILS": False, + "REDUCE_DASHBOARD_BOOTSTRAP_PAYLOAD": True, "SHARE_QUERIES_VIA_KV_STORE": False, + "SIP_38_VIZ_REARCHITECTURE": False, "TAGGING_SYSTEM": False, "SQLLAB_BACKEND_PERSISTENCE": False, + "LIST_VIEWS_NEW_UI": False, } # This is merely a default. @@ -301,11 +308,17 @@ def _try_json_readsha(filepath, length): # pylint: disable=unused-argument # role-based features, or a full on A/B testing framework. # # from flask import g, request -# def GET_FEATURE_FLAGS_FUNC(feature_flags_dict): -# feature_flags_dict['some_feature'] = g.user and g.user.id == 5 +# def GET_FEATURE_FLAGS_FUNC(feature_flags_dict: Dict[str, bool]) -> Dict[str, bool]: +# if hasattr(g, "user") and g.user.is_active: +# feature_flags_dict['some_feature'] = g.user and g.user.id == 5 # return feature_flags_dict -GET_FEATURE_FLAGS_FUNC = None +GET_FEATURE_FLAGS_FUNC: Optional[Callable[[Dict[str, bool]], Dict[str, bool]]] = None +# --------------------------------------------------- +# Thumbnail config (behind feature flag) +# --------------------------------------------------- +THUMBNAIL_SELENIUM_USER = "Admin" +THUMBNAIL_CACHE_CONFIG: CacheConfig = {"CACHE_TYPE": "null"} # --------------------------------------------------- # Image and file configuration @@ -584,6 +597,13 @@ class CeleryConfig: # pylint: disable=too-few-public-methods # dictionary. JINJA_CONTEXT_ADDONS: Dict[str, Callable] = {} +# A dictionary of macro template processors that gets merged into global +# template processors. The existing template processors get updated with this +# dictionary, which means the existing keys get overwritten by the content of this +# dictionary. The customized addons don't necessarily need to use jinjia templating +# language. This allows you to define custom logic to process macro template. +CUSTOM_TEMPLATE_PROCESSORS = {} # type: Dict[str, BaseTemplateProcessor] + # Roles that are controlled by the API / Superset and should not be changes # by humans. ROBOT_PERMISSION_ROLES = ["Public", "Gamma", "Alpha", "Admin", "sql_lab"] @@ -796,6 +816,11 @@ class CeleryConfig: # pylint: disable=too-few-public-methods # Typically these should not be allowed. PREVENT_UNSAFE_DB_CONNECTIONS = True +# Path used to store SSL certificates that are generated when using custom certs. +# Defaults to temporary directory. +# Example: SSL_CERT_PATH = "/certs" +SSL_CERT_PATH: Optional[str] = None + # SIP-15 should be enabled for all new Superset deployments which ensures that the time # range endpoints adhere to [start, end). For existing deployments admins should provide # a dedicated period of time to allow chart producers to update their charts before @@ -807,8 +832,8 @@ class CeleryConfig: # pylint: disable=too-few-public-methods SIP_15_GRACE_PERIOD_END: Optional[date] = None # exclusive SIP_15_DEFAULT_TIME_RANGE_ENDPOINTS = ["unknown", "inclusive"] SIP_15_TOAST_MESSAGE = ( - "Action Required: Preview then save your chart using the" - 'new time range endpoints <a target="_blank" href="{url}"' + "Action Required: Preview then save your chart using the " + 'new time range endpoints <a target="_blank" href="{url}" ' 'class="alert-link">here</a>.' ) diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index dfcafbfddd19..2b6e0d2630d3 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -25,6 +25,7 @@ from superset.constants import NULL_STRING from superset.models.helpers import AuditMixinNullable, ImportMixin, QueryResult from superset.models.slice import Slice +from superset.typing import FilterValue, FilterValues from superset.utils import core as utils METRIC_FORM_DATA_PARAMS = [ @@ -76,7 +77,7 @@ class BaseDatasource( # --------------------------------------------------------------- # Columns - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) description = Column(Text) default_endpoint = Column(Text) is_featured = Column(Boolean, default=False) # TODO deprecating @@ -301,28 +302,33 @@ def data_for_slices(self, slices: List[Slice]) -> Dict[str, Any]: @staticmethod def filter_values_handler( - values, target_column_is_numeric=False, is_list_target=False - ): - def handle_single_value(v): + values: Optional[FilterValues], + target_column_is_numeric: bool = False, + is_list_target: bool = False, + ) -> Optional[FilterValues]: + if values is None: + return None + + def handle_single_value(value: Optional[FilterValue]) -> Optional[FilterValue]: # backward compatibility with previous <select> components - if isinstance(v, str): - v = v.strip("\t\n'\"") + if isinstance(value, str): + value = value.strip("\t\n'\"") if target_column_is_numeric: # For backwards compatibility and edge cases # where a column data type might have changed - v = utils.string_to_num(v) - if v == NULL_STRING: + value = utils.cast_to_num(value) + if value == NULL_STRING: return None - elif v == "<empty string>": + elif value == "<empty string>": return "" - return v + return value if isinstance(values, (list, tuple)): - values = [handle_single_value(v) for v in values] + values = [handle_single_value(v) for v in values] # type: ignore else: values = handle_single_value(values) if is_list_target and not isinstance(values, (tuple, list)): - values = [values] + values = [values] # type: ignore elif not is_list_target and isinstance(values, (tuple, list)): if values: values = values[0] @@ -453,7 +459,7 @@ class BaseColumn(AuditMixinNullable, ImportMixin): __tablename__: Optional[str] = None # {connector_name}_column - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) column_name = Column(String(255), nullable=False) verbose_name = Column(String(1024)) is_active = Column(Boolean, default=True) @@ -526,7 +532,7 @@ class BaseMetric(AuditMixinNullable, ImportMixin): __tablename__: Optional[str] = None # {connector_name}_metric - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) metric_name = Column(String(255), nullable=False) verbose_name = Column(String(1024)) metric_type = Column(String(32)) diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index 517a458d4c4f..8d4aeb1ef0e5 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -24,7 +24,7 @@ from datetime import datetime, timedelta from distutils.version import LooseVersion from multiprocessing.pool import ThreadPool -from typing import Dict, Iterable, List, Optional, Set, Tuple, Union +from typing import Any, cast, Dict, Iterable, List, Optional, Set, Tuple, Union import pandas as pd import sqlalchemy as sa @@ -48,12 +48,13 @@ from sqlalchemy.orm import backref, relationship, Session from sqlalchemy_utils import EncryptedType -from superset import conf, db, security_manager +from superset import conf, db, is_feature_enabled, security_manager from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric from superset.constants import NULL_STRING from superset.exceptions import SupersetException from superset.models.core import Database from superset.models.helpers import AuditMixinNullable, ImportMixin, QueryResult +from superset.typing import FilterValues from superset.utils import core as utils, import_datasource try: @@ -80,10 +81,16 @@ pass try: - from superset.utils.core import DimSelector, DTTM_ALIAS, flasher + from superset.utils.core import ( + DimSelector, + DTTM_ALIAS, + FilterOperationType, + flasher, + ) except ImportError: pass +IS_SIP_38 = is_feature_enabled("SIP_38_VIZ_REARCHITECTURE") DRUID_TZ = conf.get("DRUID_TZ") POST_AGG_TYPE = "postagg" metadata = Model.metadata # pylint: disable=no-member @@ -657,9 +664,9 @@ def latest_metadata(self): merge=self.merge_flag, analysisTypes=[], ) - except Exception as e: + except Exception as ex: logger.warning("Failed first attempt to get latest segment") - logger.exception(e) + logger.exception(ex) if not segment_metadata: # if no segments in the past 7 days, look at all segments lbound = datetime(1901, 1, 1).isoformat()[:10] @@ -674,9 +681,9 @@ def latest_metadata(self): merge=self.merge_flag, analysisTypes=[], ) - except Exception as e: + except Exception as ex: logger.warning("Failed 2nd attempt to get latest segment") - logger.exception(e) + logger.exception(ex) if segment_metadata: return segment_metadata[-1]["columns"] @@ -810,7 +817,7 @@ def granularity( "year": "P1Y", } - granularity = {"type": "period"} + granularity: Dict[str, Union[str, float]] = {"type": "period"} if timezone: granularity["timeZone"] = timezone @@ -831,7 +838,7 @@ def granularity( granularity["period"] = period_name else: granularity["type"] = "duration" - granularity["duration"] = ( # type: ignore + granularity["duration"] = ( utils.parse_human_timedelta(period_name).total_seconds() * 1000 ) return granularity @@ -941,23 +948,24 @@ def metrics_and_post_aggs( adhoc_agg_configs = [] postagg_names = [] for metric in metrics: - if utils.is_adhoc_metric(metric): + if isinstance(metric, dict) and utils.is_adhoc_metric(metric): adhoc_agg_configs.append(metric) - elif metrics_dict[metric].metric_type != POST_AGG_TYPE: # type: ignore - saved_agg_names.add(metric) - else: - postagg_names.append(metric) + elif isinstance(metric, str): + if metrics_dict[metric].metric_type != POST_AGG_TYPE: + saved_agg_names.add(metric) + else: + postagg_names.append(metric) # Create the post aggregations, maintain order since postaggs # may depend on previous ones post_aggs: "OrderedDict[str, Postaggregator]" = OrderedDict() visited_postaggs = set() for postagg_name in postagg_names: - postagg = metrics_dict[postagg_name] # type: ignore + postagg = metrics_dict[postagg_name] visited_postaggs.add(postagg_name) DruidDatasource.resolve_postagg( postagg, post_aggs, saved_agg_names, visited_postaggs, metrics_dict ) - aggs = DruidDatasource.get_aggregations( # type: ignore + aggs = DruidDatasource.get_aggregations( metrics_dict, saved_agg_names, adhoc_agg_configs ) return aggs, post_aggs @@ -1081,11 +1089,11 @@ def get_aggregations( return aggregations def get_dimensions( - self, groupby: List[str], columns_dict: Dict[str, DruidColumn] + self, columns: List[str], columns_dict: Dict[str, DruidColumn] ) -> List[Union[str, Dict]]: dimensions = [] - groupby = [gb for gb in groupby if gb in columns_dict] - for column_name in groupby: + columns = [col for col in columns if col in columns_dict] + for column_name in columns: col = columns_dict.get(column_name) dim_spec = col.dimension_spec if col else None dimensions.append(dim_spec or column_name) @@ -1136,11 +1144,12 @@ def sanitize_metric_object(metric: Dict) -> None: def run_query( # druid self, - groupby, metrics, granularity, from_dttm, to_dttm, + columns=None, + groupby=None, filter=None, is_timeseries=True, timeseries_limit=None, @@ -1150,7 +1159,6 @@ def run_query( # druid inner_to_dttm=None, orderby=None, extras=None, - columns=None, phase=2, client=None, order_desc=True, @@ -1187,7 +1195,11 @@ def run_query( # druid ) # the dimensions list with dimensionSpecs expanded - dimensions = self.get_dimensions(groupby, columns_dict) + + dimensions = self.get_dimensions( + columns if IS_SIP_38 else groupby, columns_dict + ) + extras = extras or {} qry = dict( datasource=self.datasource_name, @@ -1213,7 +1225,9 @@ def run_query( # druid order_direction = "descending" if order_desc else "ascending" - if columns: + if (IS_SIP_38 and not metrics and "__time" not in columns) or ( + not IS_SIP_38 and columns + ): columns.append("__time") del qry["post_aggregations"] del qry["aggregations"] @@ -1223,11 +1237,20 @@ def run_query( # druid qry["granularity"] = "all" qry["limit"] = row_limit client.scan(**qry) - elif len(groupby) == 0 and not having_filters: + elif (IS_SIP_38 and columns) or ( + not IS_SIP_38 and len(groupby) == 0 and not having_filters + ): logger.info("Running timeseries query for no groupby values") del qry["dimensions"] client.timeseries(**qry) - elif not having_filters and len(groupby) == 1 and order_desc: + elif ( + not having_filters + and order_desc + and ( + (IS_SIP_38 and len(columns) == 1) + or (not IS_SIP_38 and len(groupby) == 1) + ) + ): dim = list(qry["dimensions"])[0] logger.info("Running two-phase topn query for dimension [{}]".format(dim)) pre_qry = deepcopy(qry) @@ -1278,7 +1301,10 @@ def run_query( # druid qry["metric"] = list(qry["aggregations"].keys())[0] client.topn(**qry) logger.info("Phase 2 Complete") - elif len(groupby) > 0 or having_filters: + elif ( + having_filters + or ((IS_SIP_38 and columns) or (not IS_SIP_38 and len(groupby))) > 0 + ): # If grouping on multiple fields or using a having filter # we have to force a groupby query logger.info("Running groupby query for dimensions [{}]".format(dimensions)) @@ -1363,8 +1389,8 @@ def run_query( # druid return query_str @staticmethod - def homogenize_types(df: pd.DataFrame, groupby_cols: Iterable[str]) -> pd.DataFrame: - """Converting all GROUPBY columns to strings + def homogenize_types(df: pd.DataFrame, columns: Iterable[str]) -> pd.DataFrame: + """Converting all columns to strings When grouping by a numeric (say FLOAT) column, pydruid returns strings in the dataframe. This creates issues downstream related @@ -1373,7 +1399,7 @@ def homogenize_types(df: pd.DataFrame, groupby_cols: Iterable[str]) -> pd.DataFr Here we replace None with <NULL> and make the whole series a str instead of an object. """ - df[groupby_cols] = df[groupby_cols].fillna(NULL_STRING).astype("unicode") + df[columns] = df[columns].fillna(NULL_STRING).astype("unicode") return df def query(self, query_obj: Dict) -> QueryResult: @@ -1389,7 +1415,9 @@ def query(self, query_obj: Dict) -> QueryResult: df=df, query=query_str, duration=datetime.now() - qry_start_dttm ) - df = self.homogenize_types(df, query_obj.get("groupby", [])) + df = self.homogenize_types( + df, query_obj.get("columns" if IS_SIP_38 else "groupby", []) + ) df.columns = [ DTTM_ALIAS if c in ("timestamp", "__time") else c for c in df.columns ] @@ -1404,7 +1432,9 @@ def query(self, query_obj: Dict) -> QueryResult: cols: List[str] = [] if DTTM_ALIAS in df.columns: cols += [DTTM_ALIAS] - cols += query_obj.get("groupby") or [] + + if not IS_SIP_38: + cols += query_obj.get("groupby") or [] cols += query_obj.get("columns") or [] cols += query_obj.get("metrics") or [] @@ -1459,13 +1489,20 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": """Given Superset filter data structure, returns pydruid Filter(s)""" filters = None for flt in raw_filters: - col = flt.get("col") - op = flt.get("op") - eq = flt.get("val") + col: Optional[str] = flt.get("col") + op: Optional[str] = flt["op"].upper() if "op" in flt else None + eq: Optional[FilterValues] = flt.get("val") if ( not col or not op - or (eq is None and op not in ("IS NULL", "IS NOT NULL")) + or ( + eq is None + and op + not in ( + FilterOperationType.IS_NULL.value, + FilterOperationType.IS_NOT_NULL.value, + ) + ) ): continue @@ -1479,7 +1516,10 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": cond = None is_numeric_col = col in num_cols - is_list_target = op in ("in", "not in") + is_list_target = op in ( + FilterOperationType.IN.value, + FilterOperationType.NOT_IN.value, + ) eq = cls.filter_values_handler( eq, is_list_target=is_list_target, @@ -1488,15 +1528,16 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": # For these two ops, could have used Dimension, # but it doesn't support extraction functions - if op == "==": + if op == FilterOperationType.EQUALS.value: cond = Filter( dimension=col, value=eq, extraction_function=extraction_fn ) - elif op == "!=": + elif op == FilterOperationType.NOT_EQUALS.value: cond = ~Filter( dimension=col, value=eq, extraction_function=extraction_fn ) - elif op in ("in", "not in"): + elif is_list_target: + eq = cast(list, eq) fields = [] # ignore the filter if it has no value if not len(eq): @@ -1516,9 +1557,9 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": for s in eq: fields.append(Dimension(col) == s) cond = Filter(type="or", fields=fields) - if op == "not in": + if op == FilterOperationType.NOT_IN.value: cond = ~cond - elif op == "regex": + elif op == FilterOperationType.REGEX.value: cond = Filter( extraction_function=extraction_fn, type="regex", @@ -1528,7 +1569,7 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": # For the ops below, could have used pydruid's Bound, # but it doesn't support extraction functions - elif op == ">=": + elif op == FilterOperationType.GREATER_THAN_OR_EQUALS.value: cond = Bound( extraction_function=extraction_fn, dimension=col, @@ -1538,7 +1579,7 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": upper=None, ordering=cls._get_ordering(is_numeric_col), ) - elif op == "<=": + elif op == FilterOperationType.LESS_THAN_OR_EQUALS.value: cond = Bound( extraction_function=extraction_fn, dimension=col, @@ -1548,7 +1589,7 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": upper=eq, ordering=cls._get_ordering(is_numeric_col), ) - elif op == ">": + elif op == FilterOperationType.GREATER_THAN.value: cond = Bound( extraction_function=extraction_fn, lowerStrict=True, @@ -1558,7 +1599,7 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": upper=None, ordering=cls._get_ordering(is_numeric_col), ) - elif op == "<": + elif op == FilterOperationType.LESS_THAN.value: cond = Bound( extraction_function=extraction_fn, upperStrict=True, @@ -1568,9 +1609,9 @@ def get_filters(cls, raw_filters, num_cols, columns_dict) -> "Filter": upper=eq, ordering=cls._get_ordering(is_numeric_col), ) - elif op == "IS NULL": + elif op == FilterOperationType.IS_NULL.value: cond = Filter(dimension=col, value="") - elif op == "IS NOT NULL": + elif op == FilterOperationType.IS_NOT_NULL.value: cond = ~Filter(dimension=col, value="") if filters: @@ -1586,21 +1627,25 @@ def _get_ordering(is_numeric_col: bool) -> str: def _get_having_obj(self, col: str, op: str, eq: str) -> "Having": cond = None - if op == "==": + if op == FilterOperationType.EQUALS.value: if col in self.column_names: cond = DimSelector(dimension=col, value=eq) else: cond = Aggregation(col) == eq - elif op == ">": + elif op == FilterOperationType.GREATER_THAN.value: cond = Aggregation(col) > eq - elif op == "<": + elif op == FilterOperationType.LESS_THAN.value: cond = Aggregation(col) < eq return cond - def get_having_filters(self, raw_filters: List[Dict]) -> "Having": + def get_having_filters(self, raw_filters: List[Dict[str, Any]]) -> "Having": filters = None - reversed_op_map = {"!=": "==", ">=": "<", "<=": ">"} + reversed_op_map = { + FilterOperationType.NOT_EQUALS.value: FilterOperationType.EQUALS.value, + FilterOperationType.GREATER_THAN_OR_EQUALS.value: FilterOperationType.LESS_THAN.value, + FilterOperationType.LESS_THAN_OR_EQUALS.value: FilterOperationType.GREATER_THAN.value, + } for flt in raw_filters: if not all(f in flt for f in ["col", "op", "val"]): @@ -1609,7 +1654,11 @@ def get_having_filters(self, raw_filters: List[Dict]) -> "Having": op = flt["op"] eq = flt["val"] cond = None - if op in ["==", ">", "<"]: + if op in [ + FilterOperationType.EQUALS.value, + FilterOperationType.GREATER_THAN.value, + FilterOperationType.LESS_THAN.value, + ]: cond = self._get_having_obj(col, op, eq) elif op in reversed_op_map: cond = ~self._get_having_obj(col, reversed_op_map[op], eq) diff --git a/superset/connectors/druid/views.py b/superset/connectors/druid/views.py index 231616c3b122..15e2d3df3e69 100644 --- a/superset/connectors/druid/views.py +++ b/superset/connectors/druid/views.py @@ -112,8 +112,8 @@ def pre_update(self, col): if col.dimension_spec_json: try: dimension_spec = json.loads(col.dimension_spec_json) - except ValueError as e: - raise ValueError("Invalid Dimension Spec JSON: " + str(e)) + except ValueError as ex: + raise ValueError("Invalid Dimension Spec JSON: " + str(ex)) if not isinstance(dimension_spec, dict): raise ValueError("Dimension Spec must be a JSON object") if "outputName" not in dimension_spec: @@ -374,15 +374,15 @@ def refresh_datasources(self, refresh_all=True): valid_cluster = True try: cluster.refresh_datasources(refresh_all=refresh_all) - except Exception as e: + except Exception as ex: valid_cluster = False flash( "Error while processing cluster '{}'\n{}".format( - cluster_name, utils.error_msg_from_exception(e) + cluster_name, utils.error_msg_from_exception(ex) ), "danger", ) - logger.exception(e) + logger.exception(ex) pass if valid_cluster: cluster.metadata_last_refreshed = datetime.now() diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index aaa4252e7f33..8aac7e091e78 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -49,7 +49,7 @@ from sqlalchemy.sql import column, ColumnElement, literal_column, table, text from sqlalchemy.sql.expression import Label, Select, TextAsFrom -from superset import app, db, security_manager +from superset import app, db, is_feature_enabled, security_manager from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric from superset.constants import NULL_STRING from superset.db_engine_specs.base import TimestampExpression @@ -96,11 +96,11 @@ def query(self, query_obj: Dict[str, Any]) -> QueryResult: status = utils.QueryStatus.SUCCESS try: df = pd.read_sql_query(qry.statement, db.engine) - except Exception as e: + except Exception as ex: df = pd.DataFrame() status = utils.QueryStatus.FAILED - logger.exception(e) - error_message = utils.error_msg_from_exception(e) + logger.exception(ex) + error_message = utils.error_msg_from_exception(ex) return QueryResult( status=status, df=df, duration=0, query="", error_message=error_message ) @@ -696,11 +696,12 @@ def _get_sqla_row_level_filters(self, template_processor) -> List[str]: def get_sqla_query( # sqla self, - groupby, metrics, granularity, from_dttm, to_dttm, + columns=None, + groupby=None, filter=None, is_timeseries=True, timeseries_limit=15, @@ -710,7 +711,6 @@ def get_sqla_query( # sqla inner_to_dttm=None, orderby=None, extras=None, - columns=None, order_desc=True, ) -> SqlaQuery: """Querying any sqla table from this common interface""" @@ -723,6 +723,7 @@ def get_sqla_query( # sqla "filter": filter, "columns": {col.column_name: col for col in self.columns}, } + is_sip_38 = is_feature_enabled("SIP_38_VIZ_REARCHITECTURE") template_kwargs.update(self.template_params_dict) extra_cache_keys: List[Any] = [] template_kwargs["extra_cache_keys"] = extra_cache_keys @@ -749,7 +750,11 @@ def get_sqla_query( # sqla "and is required by this type of chart" ) ) - if not groupby and not metrics and not columns: + if ( + not metrics + and not columns + and (is_sip_38 or (not is_sip_38 and not groupby)) + ): raise Exception(_("Empty query?")) metrics_exprs: List[ColumnElement] = [] for m in metrics: @@ -768,9 +773,9 @@ def get_sqla_query( # sqla select_exprs: List[Column] = [] groupby_exprs_sans_timestamp: OrderedDict = OrderedDict() - if groupby: + if (is_sip_38 and metrics and columns) or (not is_sip_38 and groupby): # dedup columns while preserving order - groupby = list(dict.fromkeys(groupby)) + groupby = list(dict.fromkeys(columns if is_sip_38 else groupby)) select_exprs = [] for s in groupby: @@ -829,7 +834,7 @@ def get_sqla_query( # sqla tbl = self.get_from_clause(template_processor) - if not columns: + if (is_sip_38 and metrics) or (not is_sip_38 and not columns): qry = qry.group_by(*groupby_exprs_with_timestamp.values()) where_clause_and = [] @@ -838,43 +843,53 @@ def get_sqla_query( # sqla if not all([flt.get(s) for s in ["col", "op"]]): continue col = flt["col"] - op = flt["op"] + op = flt["op"].upper() col_obj = cols.get(col) if col_obj: - is_list_target = op in ("in", "not in") + is_list_target = op in ( + utils.FilterOperationType.IN.value, + utils.FilterOperationType.NOT_IN.value, + ) eq = self.filter_values_handler( - flt.get("val"), + values=flt.get("val"), target_column_is_numeric=col_obj.is_numeric, is_list_target=is_list_target, ) - if op in ("in", "not in"): + if op in ( + utils.FilterOperationType.IN.value, + utils.FilterOperationType.NOT_IN.value, + ): cond = col_obj.get_sqla_col().in_(eq) - if NULL_STRING in eq: - cond = or_(cond, col_obj.get_sqla_col() == None) - if op == "not in": + if isinstance(eq, str) and NULL_STRING in eq: + cond = or_(cond, col_obj.get_sqla_col() is None) + if op == utils.FilterOperationType.NOT_IN.value: cond = ~cond where_clause_and.append(cond) else: if col_obj.is_numeric: - eq = utils.string_to_num(flt["val"]) - if op == "==": + eq = utils.cast_to_num(flt["val"]) + if op == utils.FilterOperationType.EQUALS.value: where_clause_and.append(col_obj.get_sqla_col() == eq) - elif op == "!=": + elif op == utils.FilterOperationType.NOT_EQUALS.value: where_clause_and.append(col_obj.get_sqla_col() != eq) - elif op == ">": + elif op == utils.FilterOperationType.GREATER_THAN.value: where_clause_and.append(col_obj.get_sqla_col() > eq) - elif op == "<": + elif op == utils.FilterOperationType.LESS_THAN.value: where_clause_and.append(col_obj.get_sqla_col() < eq) - elif op == ">=": + elif op == utils.FilterOperationType.GREATER_THAN_OR_EQUALS.value: where_clause_and.append(col_obj.get_sqla_col() >= eq) - elif op == "<=": + elif op == utils.FilterOperationType.LESS_THAN_OR_EQUALS.value: where_clause_and.append(col_obj.get_sqla_col() <= eq) - elif op == "LIKE": + elif op == utils.FilterOperationType.LIKE.value: where_clause_and.append(col_obj.get_sqla_col().like(eq)) - elif op == "IS NULL": - where_clause_and.append(col_obj.get_sqla_col() == None) - elif op == "IS NOT NULL": - where_clause_and.append(col_obj.get_sqla_col() != None) + elif op == utils.FilterOperationType.IS_NULL.value: + where_clause_and.append(col_obj.get_sqla_col() is None) + elif op == utils.FilterOperationType.IS_NOT_NULL.value: + where_clause_and.append(col_obj.get_sqla_col() is None) + else: + raise Exception( + _("Invalid filter operation type: %(op)s", op=op) + ) where_clause_and += self._get_sqla_row_level_filters(template_processor) if extras: @@ -892,7 +907,7 @@ def get_sqla_query( # sqla qry = qry.where(and_(*where_clause_and)) qry = qry.having(and_(*having_clause_and)) - if not orderby and not columns: + if not orderby and ((is_sip_38 and metrics) or (not is_sip_38 and not columns)): orderby = [(main_metric_expr, not order_desc)] # To ensure correct handling of the ORDER BY labeling we need to reference the @@ -914,7 +929,12 @@ def get_sqla_query( # sqla if row_limit: qry = qry.limit(row_limit) - if is_timeseries and timeseries_limit and groupby and not time_groupby_inline: + if ( + is_timeseries + and timeseries_limit + and not time_groupby_inline + and ((is_sip_38 and columns) or (not is_sip_38 and groupby)) + ): if self.database.db_engine_spec.allows_joins: # some sql dialects require for order by expressions # to also be in the select clause -- others, e.g. vertica, @@ -972,7 +992,6 @@ def get_sqla_query( # sqla prequery_obj = { "is_timeseries": False, "row_limit": timeseries_limit, - "groupby": groupby, "metrics": metrics, "granularity": granularity, "from_dttm": inner_from_dttm or from_dttm, @@ -983,6 +1002,9 @@ def get_sqla_query( # sqla "columns": columns, "order_desc": True, } + if not is_sip_38: + prequery_obj["groupby"] = groupby + result = self.query(prequery_obj) prequeries.append(result.query) dimensions = [ @@ -1055,12 +1077,12 @@ def mutator(df: pd.DataFrame) -> None: try: df = self.database.get_df(sql, self.schema, mutator) - except Exception as e: + except Exception as ex: df = pd.DataFrame() status = utils.QueryStatus.FAILED logger.exception(f"Query {sql} on schema {self.schema} failed") db_engine_spec = self.database.db_engine_spec - error_message = db_engine_spec.extract_error_message(e) + error_message = db_engine_spec.extract_error_message(ex) return QueryResult( status=status, @@ -1073,12 +1095,12 @@ def mutator(df: pd.DataFrame) -> None: def get_sqla_table_object(self) -> Table: return self.database.get_table(self.table_name, schema=self.schema) - def fetch_metadata(self) -> None: + def fetch_metadata(self, commit=True) -> None: """Fetches the metadata for the table and merges it in""" try: table = self.get_sqla_table_object() - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) raise Exception( _( "Table [{}] doesn't seem to exist in the specified database, " @@ -1086,7 +1108,6 @@ def fetch_metadata(self) -> None: ).format(self.table_name) ) - M = SqlMetric metrics = [] any_date_col = None db_engine_spec = self.database.db_engine_spec @@ -1103,10 +1124,10 @@ def fetch_metadata(self) -> None: datatype = db_engine_spec.column_datatype_to_string( col.type, db_dialect ) - except Exception as e: + except Exception as ex: datatype = "UNKNOWN" logger.error("Unrecognized data type in {}.{}".format(table, col.name)) - logger.exception(e) + logger.exception(ex) dbcol = dbcols.get(col.name, None) if not dbcol: dbcol = TableColumn(column_name=col.name, type=datatype, table=self) @@ -1123,7 +1144,7 @@ def fetch_metadata(self) -> None: any_date_col = col.name metrics.append( - M( + SqlMetric( metric_name="count", verbose_name="COUNT(*)", metric_type="count", @@ -1133,8 +1154,10 @@ def fetch_metadata(self) -> None: if not self.main_dttm_col: self.main_dttm_col = any_date_col self.add_missing_metrics(metrics) + db.session.merge(self) - db.session.commit() + if commit: + db.session.commit() @classmethod def import_obj(cls, i_datasource, import_time=None) -> int: @@ -1254,7 +1277,7 @@ class RowLevelSecurityFilter(Model, AuditMixinNullable): """ __tablename__ = "row_level_security_filters" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) roles = relationship( security_manager.role_model, secondary=RLSFilterRoles, diff --git a/superset/connectors/sqla/views.py b/superset/connectors/sqla/views.py index f9aec88666ff..38f78d2202dc 100644 --- a/superset/connectors/sqla/views.py +++ b/superset/connectors/sqla/views.py @@ -29,16 +29,17 @@ from wtforms.ext.sqlalchemy.fields import QuerySelectField from wtforms.validators import Regexp -from superset import app, appbuilder, db, security_manager +from superset import app, db, security_manager from superset.connectors.base.views import DatasourceModelView from superset.constants import RouteMethod from superset.utils import core as utils from superset.views.base import ( + create_table_permissions, DatasourceFilter, DeleteMixin, - get_datasource_exist_error_msg, ListWidgetWithCheckboxes, SupersetModelView, + validate_sqlatable, YamlExportMixin, ) @@ -375,37 +376,11 @@ class TableModelView(DatasourceModelView, DeleteMixin, YamlExportMixin): } def pre_add(self, table): - with db.session.no_autoflush: - table_query = db.session.query(models.SqlaTable).filter( - models.SqlaTable.table_name == table.table_name, - models.SqlaTable.schema == table.schema, - models.SqlaTable.database_id == table.database.id, - ) - if db.session.query(table_query.exists()).scalar(): - raise Exception(get_datasource_exist_error_msg(table.full_name)) - - # Fail before adding if the table can't be found - try: - table.get_sqla_table_object() - except Exception as e: - logger.exception(f"Got an error in pre_add for {table.name}") - raise Exception( - _( - "Table [{}] could not be found, " - "please double check your " - "database connection, schema, and " - "table name, error: {}" - ).format(table.name, str(e)) - ) + validate_sqlatable(table) def post_add(self, table, flash_message=True): table.fetch_metadata() - security_manager.add_permission_view_menu("datasource_access", table.get_perm()) - if table.schema: - security_manager.add_permission_view_menu( - "schema_access", table.schema_perm - ) - + create_table_permissions(table) if flash_message: flash( _( diff --git a/superset/dao/base.py b/superset/dao/base.py index 4f19efc4f8fb..020feed7e654 100644 --- a/superset/dao/base.py +++ b/superset/dao/base.py @@ -14,7 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from typing import Dict, Optional +from typing import Dict, List, Optional from flask_appbuilder.models.filters import BaseFilter from flask_appbuilder.models.sqla import Model @@ -48,7 +48,7 @@ class BaseDAO: @classmethod def find_by_id(cls, model_id: int) -> Model: """ - Retrives a model by id, if defined applies `base_filter` + Find a model by id, if defined applies `base_filter` """ query = db.session.query(cls.model_cls) if cls.base_filter: @@ -59,7 +59,23 @@ def find_by_id(cls, model_id: int) -> Model: return query.filter_by(id=model_id).one_or_none() @classmethod - def create(cls, properties: Dict, commit=True) -> Optional[Model]: + def find_by_ids(cls, model_ids: List[int]) -> List[Model]: + """ + Find a List of models by a list of ids, if defined applies `base_filter` + """ + id_col = getattr(cls.model_cls, "id", None) + if id_col is None: + return [] + query = db.session.query(cls.model_cls).filter(id_col.in_(model_ids)) + if cls.base_filter: + data_model = SQLAInterface(cls.model_cls, db.session) + query = cls.base_filter( # pylint: disable=not-callable + "id", data_model + ).apply(query, None) + return query.all() + + @classmethod + def create(cls, properties: Dict, commit: bool = True) -> Model: """ Generic for creating models :raises: DAOCreateFailedError @@ -73,13 +89,13 @@ def create(cls, properties: Dict, commit=True) -> Optional[Model]: db.session.add(model) if commit: db.session.commit() - except SQLAlchemyError as e: # pragma: no cover + except SQLAlchemyError as ex: # pragma: no cover db.session.rollback() - raise DAOCreateFailedError(exception=e) + raise DAOCreateFailedError(exception=ex) return model @classmethod - def update(cls, model: Model, properties: Dict, commit=True) -> Optional[Model]: + def update(cls, model: Model, properties: Dict, commit: bool = True) -> Model: """ Generic update a model :raises: DAOCreateFailedError @@ -90,13 +106,13 @@ def update(cls, model: Model, properties: Dict, commit=True) -> Optional[Model]: db.session.merge(model) if commit: db.session.commit() - except SQLAlchemyError as e: # pragma: no cover + except SQLAlchemyError as ex: # pragma: no cover db.session.rollback() - raise DAOUpdateFailedError(exception=e) + raise DAOUpdateFailedError(exception=ex) return model @classmethod - def delete(cls, model: Model, commit=True): + def delete(cls, model: Model, commit: bool = True) -> Model: """ Generic delete a model :raises: DAOCreateFailedError @@ -105,7 +121,7 @@ def delete(cls, model: Model, commit=True): db.session.delete(model) if commit: db.session.commit() - except SQLAlchemyError as e: # pragma: no cover + except SQLAlchemyError as ex: # pragma: no cover db.session.rollback() - raise DAODeleteFailedError(exception=e) + raise DAODeleteFailedError(exception=ex) return model diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index e960c4cbd2b4..c1d6590591ff 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -15,12 +15,16 @@ # specific language governing permissions and limitations # under the License. import logging +from typing import Any, Dict -from flask import g, make_response, request, Response +from flask import g, make_response, redirect, request, Response, url_for from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext +from werkzeug.wrappers import Response as WerkzeugResponse +from werkzeug.wsgi import FileWrapper +from superset import is_feature_enabled, thumbnail_cache from superset.constants import RouteMethod from superset.dashboards.commands.bulk_delete import BulkDeleteDashboardCommand from superset.dashboards.commands.create import CreateDashboardCommand @@ -35,16 +39,24 @@ DashboardUpdateFailedError, ) from superset.dashboards.commands.update import UpdateDashboardCommand -from superset.dashboards.filters import DashboardFilter +from superset.dashboards.filters import DashboardFilter, DashboardTitleOrSlugFilter from superset.dashboards.schemas import ( DashboardPostSchema, DashboardPutSchema, get_delete_ids_schema, get_export_ids_schema, + thumbnail_query_schema, ) from superset.models.dashboard import Dashboard +from superset.tasks.thumbnails import cache_dashboard_thumbnail +from superset.utils.screenshots import DashboardScreenshot from superset.views.base import generate_download_headers -from superset.views.base_api import BaseSupersetModelRestApi +from superset.views.base_api import ( + BaseSupersetModelRestApi, + RelatedFieldFilter, + statsd_metrics, +) +from superset.views.filters import FilterRelatedOwners logger = logging.getLogger(__name__) @@ -68,6 +80,8 @@ class DashboardRestApi(BaseSupersetModelRestApi): "json_metadata", "owners.id", "owners.username", + "owners.first_name", + "owners.last_name", "changed_by_name", "changed_by_url", "changed_by.username", @@ -77,6 +91,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): "url", "slug", "table_names", + "thumbnail_url", ] order_columns = ["dashboard_title", "changed_on", "published", "changed_by_fk"] list_columns = [ @@ -89,6 +104,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): "published", "slug", "url", + "thumbnail_url", ] edit_columns = [ "dashboard_title", @@ -100,6 +116,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): "published", ] search_columns = ("dashboard_title", "slug", "owners", "published") + search_filters = {"dashboard_title": [DashboardTitleOrSlugFilter]} add_columns = edit_columns base_order = ("changed_on", "desc") @@ -113,12 +130,20 @@ class DashboardRestApi(BaseSupersetModelRestApi): "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } - filter_rel_fields_field = {"owners": "first_name"} + related_field_filters = { + "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) + } allowed_rel_fields = {"owners"} + def __init__(self) -> None: + if is_feature_enabled("THUMBNAILS"): + self.include_route_methods = self.include_route_methods | {"thumbnail"} + super().__init__() + @expose("/", methods=["POST"]) @protect() @safe + @statsd_metrics def post(self) -> Response: """Creates a new Dashboard --- @@ -144,12 +169,14 @@ def post(self) -> Response: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + 302: + description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' - 422: - $ref: '#/components/responses/422' + 404: + $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ @@ -162,15 +189,16 @@ def post(self) -> Response: try: new_model = CreateDashboardCommand(g.user, item.data).run() return self.response(201, id=new_model.id, result=item.data) - except DashboardInvalidError as e: - return self.response_422(message=e.normalized_messages()) - except DashboardCreateFailedError as e: - logger.error(f"Error creating model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) + except DashboardInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except DashboardCreateFailedError as ex: + logger.error(f"Error creating model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) @expose("/<pk>", methods=["PUT"]) @protect() @safe + @statsd_metrics def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int ) -> Response: @@ -229,15 +257,16 @@ def put( # pylint: disable=too-many-return-statements, arguments-differ return self.response_404() except DashboardForbiddenError: return self.response_403() - except DashboardInvalidError as e: - return self.response_422(message=e.normalized_messages()) - except DashboardUpdateFailedError as e: - logger.error(f"Error updating model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) + except DashboardInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except DashboardUpdateFailedError as ex: + logger.error(f"Error updating model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) @expose("/<pk>", methods=["DELETE"]) @protect() @safe + @statsd_metrics def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Dashboard --- @@ -277,15 +306,18 @@ def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ return self.response_404() except DashboardForbiddenError: return self.response_403() - except DashboardDeleteFailedError as e: - logger.error(f"Error deleting model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) + except DashboardDeleteFailedError as ex: + logger.error(f"Error deleting model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @protect() @safe + @statsd_metrics @rison(get_delete_ids_schema) - def bulk_delete(self, **kwargs) -> Response: # pylint: disable=arguments-differ + def bulk_delete( + self, **kwargs: Any + ) -> Response: # pylint: disable=arguments-differ """Delete bulk Dashboards --- delete: @@ -336,14 +368,15 @@ def bulk_delete(self, **kwargs) -> Response: # pylint: disable=arguments-differ return self.response_404() except DashboardForbiddenError: return self.response_403() - except DashboardBulkDeleteFailedError as e: - return self.response_422(message=str(e)) + except DashboardBulkDeleteFailedError as ex: + return self.response_422(message=str(ex)) @expose("/export/", methods=["GET"]) @protect() @safe + @statsd_metrics @rison(get_export_ids_schema) - def export(self, **kwargs): + def export(self, **kwargs: Any) -> Response: """Export dashboards --- get: @@ -389,3 +422,87 @@ def export(self, **kwargs): "Content-Disposition" ] return resp + + @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) + @protect() + @safe + @rison(thumbnail_query_schema) + def thumbnail( + self, pk: int, digest: str, **kwargs: Dict[str, bool] + ) -> WerkzeugResponse: + """Get Dashboard thumbnail + --- + get: + description: >- + Compute async or get already computed dashboard thumbnail from cache + parameters: + - in: path + schema: + type: integer + name: pk + - in: path + name: digest + description: A hex digest that makes this dashboard unique + schema: + type: string + - in: query + name: q + content: + application/json: + schema: + type: object + properties: + force: + type: boolean + default: false + responses: + 200: + description: Dashboard thumbnail image + content: + image/*: + schema: + type: string + format: binary + 202: + description: Thumbnail does not exist on cache, fired async to compute + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + dashboard = self.datamodel.get(pk, self._base_filters) + if not dashboard: + return self.response_404() + # If force, request a screenshot from the workers + if kwargs["rison"].get("force", False): + cache_dashboard_thumbnail.delay(dashboard.id, force=True) + return self.response(202, message="OK Async") + # fetch the dashboard screenshot using the current user and cache if set + screenshot = DashboardScreenshot(pk).get_from_cache(cache=thumbnail_cache) + # If the screenshot does not exist, request one from the workers + if not screenshot: + cache_dashboard_thumbnail.delay(dashboard.id, force=True) + return self.response(202, message="OK Async") + # If digests + if dashboard.digest != digest: + return redirect( + url_for( + f"{self.__class__.__name__}.thumbnail", + pk=pk, + digest=dashboard.digest, + ) + ) + return Response( + FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True + ) diff --git a/superset/dashboards/commands/bulk_delete.py b/superset/dashboards/commands/bulk_delete.py index 07f2bef78742..cb2bab62e9af 100644 --- a/superset/dashboards/commands/bulk_delete.py +++ b/superset/dashboards/commands/bulk_delete.py @@ -40,12 +40,13 @@ def __init__(self, user: User, model_ids: List[int]): self._model_ids = model_ids self._models: Optional[List[Dashboard]] = None - def run(self): + def run(self) -> None: self.validate() try: DashboardDAO.bulk_delete(self._models) - except DeleteFailedError as e: - logger.exception(e.exception) + return None + except DeleteFailedError as ex: + logger.exception(ex.exception) raise DashboardBulkDeleteFailedError() def validate(self) -> None: diff --git a/superset/dashboards/commands/create.py b/superset/dashboards/commands/create.py index da79677465e9..0aa1241fdad2 100644 --- a/superset/dashboards/commands/create.py +++ b/superset/dashboards/commands/create.py @@ -17,6 +17,7 @@ import logging from typing import Dict, List, Optional +from flask_appbuilder.models.sqla import Model from flask_appbuilder.security.sqla.models import User from marshmallow import ValidationError @@ -38,12 +39,13 @@ def __init__(self, user: User, data: Dict): self._actor = user self._properties = data.copy() - def run(self): + def run(self) -> Model: self.validate() try: - dashboard = DashboardDAO.create(self._properties) - except DAOCreateFailedError as e: - logger.exception(e.exception) + dashboard = DashboardDAO.create(self._properties, commit=False) + dashboard = DashboardDAO.update_charts_owners(dashboard, commit=True) + except DAOCreateFailedError as ex: + logger.exception(ex.exception) raise DashboardCreateFailedError() return dashboard @@ -59,8 +61,8 @@ def validate(self) -> None: try: owners = populate_owners(self._actor, owner_ids) self._properties["owners"] = owners - except ValidationError as e: - exceptions.append(e) + except ValidationError as ex: + exceptions.append(ex) if exceptions: exception = DashboardInvalidError() exception.add_list(exceptions) diff --git a/superset/dashboards/commands/delete.py b/superset/dashboards/commands/delete.py index bc19b7cae7f7..08d0fcb1dd24 100644 --- a/superset/dashboards/commands/delete.py +++ b/superset/dashboards/commands/delete.py @@ -17,6 +17,7 @@ import logging from typing import Optional +from flask_appbuilder.models.sqla import Model from flask_appbuilder.security.sqla.models import User from superset.commands.base import BaseCommand @@ -40,12 +41,12 @@ def __init__(self, user: User, model_id: int): self._model_id = model_id self._model: Optional[Dashboard] = None - def run(self): + def run(self) -> Model: self.validate() try: dashboard = DashboardDAO.delete(self._model) - except DAODeleteFailedError as e: - logger.exception(e.exception) + except DAODeleteFailedError as ex: + logger.exception(ex.exception) raise DashboardDeleteFailedError() return dashboard diff --git a/superset/dashboards/commands/exceptions.py b/superset/dashboards/commands/exceptions.py index 76d7237bb6a4..13b0d5b74bc9 100644 --- a/superset/dashboards/commands/exceptions.py +++ b/superset/dashboards/commands/exceptions.py @@ -32,7 +32,7 @@ class DashboardSlugExistsValidationError(ValidationError): Marshmallow validation error for dashboard slug already exists """ - def __init__(self): + def __init__(self) -> None: super().__init__(_("Must be unique"), field_names=["slug"]) diff --git a/superset/dashboards/commands/update.py b/superset/dashboards/commands/update.py index 27cd13dd4a82..7746b7e8c55b 100644 --- a/superset/dashboards/commands/update.py +++ b/superset/dashboards/commands/update.py @@ -17,12 +17,12 @@ import logging from typing import Dict, List, Optional +from flask_appbuilder.models.sqla import Model from flask_appbuilder.security.sqla.models import User from marshmallow import ValidationError from superset.commands.base import BaseCommand from superset.commands.utils import populate_owners -from superset.connectors.sqla.models import SqlaTable from superset.dao.exceptions import DAOUpdateFailedError from superset.dashboards.commands.exceptions import ( DashboardForbiddenError, @@ -33,6 +33,7 @@ ) from superset.dashboards.dao import DashboardDAO from superset.exceptions import SupersetSecurityException +from superset.models.dashboard import Dashboard from superset.views.base import check_ownership logger = logging.getLogger(__name__) @@ -43,21 +44,22 @@ def __init__(self, user: User, model_id: int, data: Dict): self._actor = user self._model_id = model_id self._properties = data.copy() - self._model: Optional[SqlaTable] = None + self._model: Optional[Dashboard] = None - def run(self): + def run(self) -> Model: self.validate() try: - dashboard = DashboardDAO.update(self._model, self._properties) - except DAOUpdateFailedError as e: - logger.exception(e.exception) + dashboard = DashboardDAO.update(self._model, self._properties, commit=False) + dashboard = DashboardDAO.update_charts_owners(dashboard, commit=True) + except DAOUpdateFailedError as ex: + logger.exception(ex.exception) raise DashboardUpdateFailedError() return dashboard def validate(self) -> None: exceptions: List[ValidationError] = [] owner_ids: Optional[List[int]] = self._properties.get("owners") - slug: str = self._properties.get("slug", "") + slug: Optional[str] = self._properties.get("slug") # Validate/populate model exists self._model = DashboardDAO.find_by_id(self._model_id) @@ -79,8 +81,8 @@ def validate(self) -> None: try: owners = populate_owners(self._actor, owner_ids) self._properties["owners"] = owners - except ValidationError as e: - exceptions.append(e) + except ValidationError as ex: + exceptions.append(ex) if exceptions: exception = DashboardInvalidError() exception.add_list(exceptions) diff --git a/superset/dashboards/dao.py b/superset/dashboards/dao.py index e635750bec69..6e12cd547ff3 100644 --- a/superset/dashboards/dao.py +++ b/superset/dashboards/dao.py @@ -15,9 +15,8 @@ # specific language governing permissions and limitations # under the License. import logging -from typing import List +from typing import List, Optional -from flask_appbuilder.models.sqla.interface import SQLAInterface from sqlalchemy.exc import SQLAlchemyError from superset.dao.base import BaseDAO @@ -32,13 +31,6 @@ class DashboardDAO(BaseDAO): model_cls = Dashboard base_filter = DashboardFilter - @staticmethod - def find_by_ids(model_ids: List[int]) -> List[Dashboard]: - query = db.session.query(Dashboard).filter(Dashboard.id.in_(model_ids)) - data_model = SQLAInterface(Dashboard, db.session) - query = DashboardFilter("id", data_model).apply(query, None) - return query.all() - @staticmethod def validate_slug_uniqueness(slug: str) -> bool: if not slug: @@ -47,20 +39,32 @@ def validate_slug_uniqueness(slug: str) -> bool: return not db.session.query(dashboard_query.exists()).scalar() @staticmethod - def validate_update_slug_uniqueness(dashboard_id: int, slug: str) -> bool: - dashboard_query = db.session.query(Dashboard).filter( - Dashboard.slug == slug, Dashboard.id != dashboard_id - ) - return not db.session.query(dashboard_query.exists()).scalar() + def validate_update_slug_uniqueness(dashboard_id: int, slug: Optional[str]) -> bool: + if slug is not None: + dashboard_query = db.session.query(Dashboard).filter( + Dashboard.slug == slug, Dashboard.id != dashboard_id + ) + return not db.session.query(dashboard_query.exists()).scalar() + return True + + @staticmethod + def update_charts_owners(model: Dashboard, commit: bool = True) -> Dashboard: + owners = [owner for owner in model.owners] + for slc in model.slices: + slc.owners = list(set(owners) | set(slc.owners)) + if commit: + db.session.commit() + return model @staticmethod - def bulk_delete(models: List[Dashboard], commit=True): - item_ids = [model.id for model in models] + def bulk_delete(models: Optional[List[Dashboard]], commit: bool = True) -> None: + item_ids = [model.id for model in models] if models else [] # bulk delete, first delete related data - for model in models: - model.slices = [] - model.owners = [] - db.session.merge(model) + if models: + for model in models: + model.slices = [] + model.owners = [] + db.session.merge(model) # bulk delete itself try: db.session.query(Dashboard).filter(Dashboard.id.in_(item_ids)).delete( @@ -68,7 +72,7 @@ def bulk_delete(models: List[Dashboard], commit=True): ) if commit: db.session.commit() - except SQLAlchemyError as e: + except SQLAlchemyError as ex: if commit: db.session.rollback() - raise e + raise ex diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py index 0b4338dfdbad..05a6f6e4a844 100644 --- a/superset/dashboards/filters.py +++ b/superset/dashboards/filters.py @@ -14,7 +14,11 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Any + +from flask_babel import lazy_gettext as _ from sqlalchemy import and_, or_ +from sqlalchemy.orm.query import Query from superset import db, security_manager from superset.models.core import FavStar @@ -23,6 +27,22 @@ from superset.views.base import BaseFilter, get_user_roles +class DashboardTitleOrSlugFilter(BaseFilter): # pylint: disable=too-few-public-methods + name = _("Title or Slug") + arg_name = "title_or_slug" + + def apply(self, query: Query, value: Any) -> Query: + if not value: + return query + ilike_value = f"%{value}%" + return query.filter( + or_( + Dashboard.dashboard_title.ilike(ilike_value), + Dashboard.slug.ilike(ilike_value), + ) + ) + + class DashboardFilter(BaseFilter): # pylint: disable=too-few-public-methods """ List dashboards with the following criteria: @@ -35,7 +55,7 @@ class DashboardFilter(BaseFilter): # pylint: disable=too-few-public-methods if they wish to see those dashboards which are published first """ - def apply(self, query, value): + def apply(self, query: Query, value: Any) -> Query: user_roles = [role.name.lower() for role in list(get_user_roles())] if "admin" in user_roles: return query diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index a6b7cd4e27f5..201c4ca49d80 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -16,6 +16,7 @@ # under the License. import json import re +from typing import Any, Dict, Union from marshmallow import fields, pre_load, Schema from marshmallow.validate import Length, ValidationError @@ -25,16 +26,20 @@ get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} +thumbnail_query_schema = { + "type": "object", + "properties": {"force": {"type": "boolean"}}, +} -def validate_json(value): +def validate_json(value: Union[bytes, bytearray, str]) -> None: try: utils.validate_json(value) except SupersetException: raise ValidationError("JSON not valid") -def validate_json_metadata(value): +def validate_json_metadata(value: Union[bytes, bytearray, str]) -> None: if not value: return try: @@ -60,7 +65,7 @@ class DashboardJSONMetadataSchema(Schema): class BaseDashboardSchema(Schema): @pre_load - def pre_load(self, data): # pylint: disable=no-self-use + def pre_load(self, data: Dict[str, Any]) -> None: # pylint: disable=no-self-use if data.get("slug"): data["slug"] = data["slug"].strip() data["slug"] = data["slug"].replace(" ", "-") diff --git a/superset/dataframe.py b/superset/dataframe.py index 746a2a45c391..e8cd0d13e665 100644 --- a/superset/dataframe.py +++ b/superset/dataframe.py @@ -26,10 +26,10 @@ def df_to_records(dframe: pd.DataFrame) -> List[Dict[str, Any]]: data: List[Dict[str, Any]] = dframe.to_dict(orient="records") # TODO: refactor this - for d in data: - for k, v in list(d.items()): + for row in data: + for key, value in list(row.items()): # if an int is too big for JavaScript to handle # convert it to a string - if isinstance(v, int) and abs(v) > JS_MAX_INTEGER: - d[k] = str(v) + if isinstance(value, int) and abs(value) > JS_MAX_INTEGER: + row[key] = str(value) return data diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 2e12629c42f1..b7c7a54ecbe2 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -15,9 +15,11 @@ # specific language governing permissions and limitations # under the License. import logging +from typing import Any +import yaml from flask import g, request, Response -from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from superset.connectors.sqla.models import SqlaTable @@ -30,13 +32,20 @@ DatasetForbiddenError, DatasetInvalidError, DatasetNotFoundError, + DatasetRefreshFailedError, DatasetUpdateFailedError, ) +from superset.datasets.commands.refresh import RefreshDatasetCommand from superset.datasets.commands.update import UpdateDatasetCommand -from superset.datasets.schemas import DatasetPostSchema, DatasetPutSchema -from superset.views.base import DatasourceFilter -from superset.views.base_api import BaseSupersetModelRestApi +from superset.datasets.schemas import ( + DatasetPostSchema, + DatasetPutSchema, + get_export_ids_schema, +) +from superset.views.base import DatasourceFilter, generate_download_headers +from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter from superset.views.database.filters import DatabaseFilter +from superset.views.filters import FilterRelatedOwners logger = logging.getLogger(__name__) @@ -49,9 +58,13 @@ class DatasetRestApi(BaseSupersetModelRestApi): allow_browser_login = True class_permission_name = "TableModelView" - include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {RouteMethod.RELATED} - + include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { + RouteMethod.EXPORT, + RouteMethod.RELATED, + "refresh", + } list_columns = [ + "database_name", "changed_by_name", "changed_by_url", "changed_by.username", @@ -79,6 +92,10 @@ class DatasetRestApi(BaseSupersetModelRestApi): "template_params", "owners.id", "owners.username", + "owners.first_name", + "owners.last_name", + "columns", + "metrics", ] add_model_schema = DatasetPostSchema() edit_model_schema = DatasetPutSchema() @@ -97,10 +114,14 @@ class DatasetRestApi(BaseSupersetModelRestApi): "is_sqllab_view", "template_params", "owners", + "columns", + "metrics", ] openapi_spec_tag = "Datasets" - - filter_rel_fields_field = {"owners": "first_name", "database": "database_name"} + related_field_filters = { + "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), + "database": "database_name", + } filter_rel_fields = {"database": [["id", DatabaseFilter, lambda: []]]} allowed_rel_fields = {"database", "owners"} @@ -150,11 +171,11 @@ def post(self) -> Response: try: new_model = CreateDatasetCommand(g.user, item.data).run() return self.response(201, id=new_model.id, result=item.data) - except DatasetInvalidError as e: - return self.response_422(message=e.normalized_messages()) - except DatasetCreateFailedError as e: - logger.error(f"Error creating model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) + except DatasetInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except DatasetCreateFailedError as ex: + logger.error(f"Error creating model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) @expose("/<pk>", methods=["PUT"]) @protect() @@ -217,11 +238,11 @@ def put( # pylint: disable=too-many-return-statements, arguments-differ return self.response_404() except DatasetForbiddenError: return self.response_403() - except DatasetInvalidError as e: - return self.response_422(message=e.normalized_messages()) - except DatasetUpdateFailedError as e: - logger.error(f"Error updating model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) + except DatasetInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except DatasetUpdateFailedError as ex: + logger.error(f"Error updating model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) @expose("/<pk>", methods=["DELETE"]) @protect() @@ -265,6 +286,104 @@ def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ return self.response_404() except DatasetForbiddenError: return self.response_403() - except DatasetDeleteFailedError as e: - logger.error(f"Error deleting model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) + except DatasetDeleteFailedError as ex: + logger.error(f"Error deleting model {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) + + @expose("/export/", methods=["GET"]) + @protect() + @safe + @rison(get_export_ids_schema) + def export(self, **kwargs: Any) -> Response: + """Export dashboards + --- + get: + description: >- + Exports multiple datasets and downloads them as YAML files + parameters: + - in: query + name: q + content: + application/json: + schema: + type: array + items: + type: integer + responses: + 200: + description: Dataset export + content: + text/plain: + schema: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + requested_ids = kwargs["rison"] + query = self.datamodel.session.query(SqlaTable).filter( + SqlaTable.id.in_(requested_ids) + ) + query = self._base_filters.apply_all(query) + items = query.all() + ids = [item.id for item in items] + if len(ids) != len(requested_ids): + return self.response_404() + + data = [t.export_to_dict() for t in items] + return Response( + yaml.safe_dump(data), + headers=generate_download_headers("yaml"), + mimetype="application/text", + ) + + @expose("/<pk>/refresh", methods=["PUT"]) + @protect() + @safe + def refresh(self, pk: int) -> Response: + """Refresh a Dataset + --- + put: + description: >- + Refreshes and updates columns of a dataset + parameters: + - in: path + schema: + type: integer + name: pk + responses: + 200: + description: Dataset delete + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + RefreshDatasetCommand(g.user, pk).run() + return self.response(200, message="OK") + except DatasetNotFoundError: + return self.response_404() + except DatasetForbiddenError: + return self.response_403() + except DatasetRefreshFailedError as ex: + logger.error(f"Error refreshing dataset {self.__class__.__name__}: {ex}") + return self.response_422(message=str(ex)) diff --git a/superset/datasets/commands/create.py b/superset/datasets/commands/create.py index 466e35dd7f81..3114a4f005d7 100644 --- a/superset/datasets/commands/create.py +++ b/superset/datasets/commands/create.py @@ -17,8 +17,10 @@ import logging from typing import Dict, List, Optional +from flask_appbuilder.models.sqla import Model from flask_appbuilder.security.sqla.models import User from marshmallow import ValidationError +from sqlalchemy.exc import SQLAlchemyError from superset.commands.base import BaseCommand from superset.commands.utils import populate_owners @@ -31,6 +33,7 @@ TableNotFoundValidationError, ) from superset.datasets.dao import DatasetDAO +from superset.extensions import db, security_manager logger = logging.getLogger(__name__) @@ -40,12 +43,26 @@ def __init__(self, user: User, data: Dict): self._actor = user self._properties = data.copy() - def run(self): + def run(self) -> Model: self.validate() try: - dataset = DatasetDAO.create(self._properties) - except DAOCreateFailedError as e: - logger.exception(e.exception) + # Creates SqlaTable (Dataset) + dataset = DatasetDAO.create(self._properties, commit=False) + # Updates columns and metrics from the dataset + dataset.fetch_metadata(commit=False) + # Add datasource access permission + security_manager.add_permission_view_menu( + "datasource_access", dataset.get_perm() + ) + # Add schema access permission if exists + if dataset.schema: + security_manager.add_permission_view_menu( + "schema_access", dataset.schema_perm + ) + db.session.commit() + except (SQLAlchemyError, DAOCreateFailedError) as ex: + logger.exception(ex) + db.session.rollback() raise DatasetCreateFailedError() return dataset @@ -75,8 +92,8 @@ def validate(self) -> None: try: owners = populate_owners(self._actor, owner_ids) self._properties["owners"] = owners - except ValidationError as e: - exceptions.append(e) + except ValidationError as ex: + exceptions.append(ex) if exceptions: exception = DatasetInvalidError() exception.add_list(exceptions) diff --git a/superset/datasets/commands/delete.py b/superset/datasets/commands/delete.py index 85837f96f7fb..551d222dfbcd 100644 --- a/superset/datasets/commands/delete.py +++ b/superset/datasets/commands/delete.py @@ -17,7 +17,9 @@ import logging from typing import Optional +from flask_appbuilder.models.sqla import Model from flask_appbuilder.security.sqla.models import User +from sqlalchemy.exc import SQLAlchemyError from superset.commands.base import BaseCommand from superset.connectors.sqla.models import SqlaTable @@ -29,6 +31,7 @@ ) from superset.datasets.dao import DatasetDAO from superset.exceptions import SupersetSecurityException +from superset.extensions import db, security_manager from superset.views.base import check_ownership logger = logging.getLogger(__name__) @@ -40,12 +43,17 @@ def __init__(self, user: User, model_id: int): self._model_id = model_id self._model: Optional[SqlaTable] = None - def run(self): + def run(self) -> Model: self.validate() try: - dataset = DatasetDAO.delete(self._model) - except DAODeleteFailedError as e: - logger.exception(e.exception) + dataset = DatasetDAO.delete(self._model, commit=False) + security_manager.del_permission_view_menu( + "datasource_access", dataset.get_perm() + ) + db.session.commit() + except (SQLAlchemyError, DAODeleteFailedError) as ex: + logger.exception(ex) + db.session.rollback() raise DatasetDeleteFailedError() return dataset diff --git a/superset/datasets/commands/exceptions.py b/superset/datasets/commands/exceptions.py index a6d0ed7deda3..e5f3ae292801 100644 --- a/superset/datasets/commands/exceptions.py +++ b/superset/datasets/commands/exceptions.py @@ -33,7 +33,7 @@ class DatabaseNotFoundValidationError(ValidationError): Marshmallow validation error for database does not exist """ - def __init__(self): + def __init__(self) -> None: super().__init__(_("Database does not exist"), field_names=["database"]) @@ -42,7 +42,7 @@ class DatabaseChangeValidationError(ValidationError): Marshmallow validation error database changes are not allowed on update """ - def __init__(self): + def __init__(self) -> None: super().__init__(_("Database not allowed to change"), field_names=["database"]) @@ -51,18 +51,80 @@ class DatasetExistsValidationError(ValidationError): Marshmallow validation error for dataset already exists """ - def __init__(self, table_name: str): + def __init__(self, table_name: str) -> None: super().__init__( get_datasource_exist_error_msg(table_name), field_names=["table_name"] ) +class DatasetColumnNotFoundValidationError(ValidationError): + """ + Marshmallow validation error when dataset column for update does not exist + """ + + def __init__(self) -> None: + super().__init__(_("One or more columns do not exist"), field_names=["columns"]) + + +class DatasetColumnsDuplicateValidationError(ValidationError): + """ + Marshmallow validation error when dataset columns have a duplicate on the list + """ + + def __init__(self) -> None: + super().__init__( + _("One or more columns are duplicated"), field_names=["columns"] + ) + + +class DatasetColumnsExistsValidationError(ValidationError): + """ + Marshmallow validation error when dataset columns already exist + """ + + def __init__(self) -> None: + super().__init__( + _("One or more columns already exist"), field_names=["columns"] + ) + + +class DatasetMetricsNotFoundValidationError(ValidationError): + """ + Marshmallow validation error when dataset metric for update does not exist + """ + + def __init__(self) -> None: + super().__init__(_("One or more metrics do not exist"), field_names=["metrics"]) + + +class DatasetMetricsDuplicateValidationError(ValidationError): + """ + Marshmallow validation error when dataset metrics have a duplicate on the list + """ + + def __init__(self) -> None: + super().__init__( + _("One or more metrics are duplicated"), field_names=["metrics"] + ) + + +class DatasetMetricsExistsValidationError(ValidationError): + """ + Marshmallow validation error when dataset metrics already exist + """ + + def __init__(self) -> None: + super().__init__( + _("One or more metrics already exist"), field_names=["metrics"] + ) + + class TableNotFoundValidationError(ValidationError): """ Marshmallow validation error when a table does not exist on the database """ - def __init__(self, table_name: str): + def __init__(self, table_name: str) -> None: super().__init__( _( f"Table [{table_name}] could not be found, " @@ -75,7 +137,7 @@ def __init__(self, table_name: str): class OwnersNotFoundValidationError(ValidationError): - def __init__(self): + def __init__(self) -> None: super().__init__(_("Owners are invalid"), field_names=["owners"]) @@ -99,5 +161,9 @@ class DatasetDeleteFailedError(DeleteFailedError): message = _("Dataset could not be deleted.") +class DatasetRefreshFailedError(UpdateFailedError): + message = _("Dataset could not be updated.") + + class DatasetForbiddenError(ForbiddenError): message = _("Changing this dataset is forbidden") diff --git a/superset/datasets/commands/refresh.py b/superset/datasets/commands/refresh.py new file mode 100644 index 000000000000..22869570bd9f --- /dev/null +++ b/superset/datasets/commands/refresh.py @@ -0,0 +1,63 @@ +# 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 logging +from typing import Optional + +from flask_appbuilder.models.sqla import Model +from flask_appbuilder.security.sqla.models import User + +from superset.commands.base import BaseCommand +from superset.connectors.sqla.models import SqlaTable +from superset.datasets.commands.exceptions import ( + DatasetForbiddenError, + DatasetNotFoundError, + DatasetRefreshFailedError, +) +from superset.datasets.dao import DatasetDAO +from superset.exceptions import SupersetSecurityException +from superset.views.base import check_ownership + +logger = logging.getLogger(__name__) + + +class RefreshDatasetCommand(BaseCommand): + def __init__(self, user: User, model_id: int): + self._actor = user + self._model_id = model_id + self._model: Optional[SqlaTable] = None + + def run(self) -> Model: + self.validate() + if self._model: + try: + self._model.fetch_metadata() + return self._model + except Exception as ex: + logger.exception(ex) + raise DatasetRefreshFailedError() + raise DatasetRefreshFailedError() + + def validate(self) -> None: + # Validate/populate model exists + self._model = DatasetDAO.find_by_id(self._model_id) + if not self._model: + raise DatasetNotFoundError() + # Check ownership + try: + check_ownership(self._model) + except SupersetSecurityException: + raise DatasetForbiddenError() diff --git a/superset/datasets/commands/update.py b/superset/datasets/commands/update.py index 05a0b96664bd..c7f70dd16cc6 100644 --- a/superset/datasets/commands/update.py +++ b/superset/datasets/commands/update.py @@ -15,8 +15,10 @@ # specific language governing permissions and limitations # under the License. import logging +from collections import Counter from typing import Dict, List, Optional +from flask_appbuilder.models.sqla import Model from flask_appbuilder.security.sqla.models import User from marshmallow import ValidationError @@ -26,9 +28,15 @@ from superset.dao.exceptions import DAOUpdateFailedError from superset.datasets.commands.exceptions import ( DatabaseChangeValidationError, + DatasetColumnNotFoundValidationError, + DatasetColumnsDuplicateValidationError, + DatasetColumnsExistsValidationError, DatasetExistsValidationError, DatasetForbiddenError, DatasetInvalidError, + DatasetMetricsDuplicateValidationError, + DatasetMetricsExistsValidationError, + DatasetMetricsNotFoundValidationError, DatasetNotFoundError, DatasetUpdateFailedError, ) @@ -46,14 +54,16 @@ def __init__(self, user: User, model_id: int, data: Dict): self._properties = data.copy() self._model: Optional[SqlaTable] = None - def run(self): + def run(self) -> Model: self.validate() - try: - dataset = DatasetDAO.update(self._model, self._properties) - except DAOUpdateFailedError as e: - logger.exception(e.exception) - raise DatasetUpdateFailedError() - return dataset + if self._model: + try: + dataset = DatasetDAO.update(self._model, self._properties) + return dataset + except DAOUpdateFailedError as ex: + logger.exception(ex.exception) + raise DatasetUpdateFailedError() + raise DatasetUpdateFailedError() def validate(self) -> None: exceptions = list() @@ -82,9 +92,70 @@ def validate(self) -> None: try: owners = populate_owners(self._actor, owner_ids) self._properties["owners"] = owners - except ValidationError as e: - exceptions.append(e) + except ValidationError as ex: + exceptions.append(ex) + + # Validate columns + columns = self._properties.get("columns") + if columns: + self._validate_columns(columns, exceptions) + + # Validate metrics + metrics = self._properties.get("metrics") + if metrics: + self._validate_metrics(metrics, exceptions) + if exceptions: exception = DatasetInvalidError() exception.add_list(exceptions) raise exception + + def _validate_columns( + self, columns: List[Dict], exceptions: List[ValidationError] + ) -> None: + # Validate duplicates on data + if self._get_duplicates(columns, "column_name"): + exceptions.append(DatasetColumnsDuplicateValidationError()) + else: + # validate invalid id's + columns_ids: List[int] = [ + column["id"] for column in columns if "id" in column + ] + if not DatasetDAO.validate_columns_exist(self._model_id, columns_ids): + exceptions.append(DatasetColumnNotFoundValidationError()) + # validate new column names uniqueness + columns_names: List[str] = [ + column["column_name"] for column in columns if "id" not in column + ] + if not DatasetDAO.validate_columns_uniqueness( + self._model_id, columns_names + ): + exceptions.append(DatasetColumnsExistsValidationError()) + + def _validate_metrics( + self, metrics: List[Dict], exceptions: List[ValidationError] + ) -> None: + if self._get_duplicates(metrics, "metric_name"): + exceptions.append(DatasetMetricsDuplicateValidationError()) + else: + # validate invalid id's + metrics_ids: List[int] = [ + metric["id"] for metric in metrics if "id" in metric + ] + if not DatasetDAO.validate_metrics_exist(self._model_id, metrics_ids): + exceptions.append(DatasetMetricsNotFoundValidationError()) + # validate new metric names uniqueness + metric_names: List[str] = [ + metric["metric_name"] for metric in metrics if "id" not in metric + ] + if not DatasetDAO.validate_metrics_uniqueness(self._model_id, metric_names): + exceptions.append(DatasetMetricsExistsValidationError()) + + @staticmethod + def _get_duplicates(data: List[Dict], key: str) -> List[str]: + duplicates = [ + name + for name, count in Counter([item[key] for item in data]).items() + if count > 1 + ] + return duplicates diff --git a/superset/datasets/dao.py b/superset/datasets/dao.py index 7e08ce8c0c99..5dfe4ef49e00 100644 --- a/superset/datasets/dao.py +++ b/superset/datasets/dao.py @@ -15,18 +15,13 @@ # specific language governing permissions and limitations # under the License. import logging -from typing import Dict, Optional +from typing import Dict, List, Optional from flask import current_app -from flask_appbuilder.models.sqla.interface import SQLAInterface from sqlalchemy.exc import SQLAlchemyError -from superset.commands.exceptions import ( - CreateFailedError, - DeleteFailedError, - UpdateFailedError, -) -from superset.connectors.sqla.models import SqlaTable +from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn +from superset.dao.base import BaseDAO from superset.extensions import db from superset.models.core import Database from superset.views.base import DatasourceFilter @@ -34,7 +29,10 @@ logger = logging.getLogger(__name__) -class DatasetDAO: +class DatasetDAO(BaseDAO): + model_cls = SqlaTable + base_filter = DatasourceFilter + @staticmethod def get_owner_by_id(owner_id: int) -> Optional[object]: return ( @@ -44,11 +42,11 @@ def get_owner_by_id(owner_id: int) -> Optional[object]: ) @staticmethod - def get_database_by_id(database_id) -> Optional[Database]: + def get_database_by_id(database_id: int) -> Optional[Database]: try: return db.session.query(Database).filter_by(id=database_id).one_or_none() - except SQLAlchemyError as e: # pragma: no cover - logger.error(f"Could not get database by id: {e}") + except SQLAlchemyError as ex: # pragma: no cover + logger.error(f"Could not get database by id: {ex}") return None @staticmethod @@ -56,8 +54,8 @@ def validate_table_exists(database: Database, table_name: str, schema: str) -> b try: database.get_table(table_name, schema=schema) return True - except SQLAlchemyError as e: # pragma: no cover - logger.error(f"Got an error {e} validating table: {table_name}") + except SQLAlchemyError as ex: # pragma: no cover + logger.error(f"Got an error {ex} validating table: {table_name}") return False @staticmethod @@ -79,47 +77,112 @@ def validate_update_uniqueness( return not db.session.query(dataset_query.exists()).scalar() @staticmethod - def find_by_id(model_id: int) -> SqlaTable: - data_model = SQLAInterface(SqlaTable, db.session) - query = db.session.query(SqlaTable) - query = DatasourceFilter("id", data_model).apply(query, None) - return query.filter_by(id=model_id).one_or_none() + def validate_columns_exist(dataset_id: int, columns_ids: List[int]) -> bool: + dataset_query = ( + db.session.query(TableColumn.id).filter( + TableColumn.table_id == dataset_id, TableColumn.id.in_(columns_ids) + ) + ).all() + return len(columns_ids) == len(dataset_query) @staticmethod - def create(properties: Dict, commit=True) -> Optional[SqlaTable]: - model = SqlaTable() - for key, value in properties.items(): - setattr(model, key, value) - try: - db.session.add(model) - if commit: - db.session.commit() - except SQLAlchemyError as e: # pragma: no cover - db.session.rollback() - raise CreateFailedError(exception=e) - return model + def validate_columns_uniqueness(dataset_id: int, columns_names: List[str]) -> bool: + dataset_query = ( + db.session.query(TableColumn.id).filter( + TableColumn.table_id == dataset_id, + TableColumn.column_name.in_(columns_names), + ) + ).all() + return len(dataset_query) == 0 @staticmethod - def update(model: SqlaTable, properties: Dict, commit=True) -> Optional[SqlaTable]: - for key, value in properties.items(): - setattr(model, key, value) - try: - db.session.merge(model) - if commit: - db.session.commit() - except SQLAlchemyError as e: # pragma: no cover - db.session.rollback() - raise UpdateFailedError(exception=e) - return model + def validate_metrics_exist(dataset_id: int, metrics_ids: List[int]) -> bool: + dataset_query = ( + db.session.query(SqlMetric.id).filter( + SqlMetric.table_id == dataset_id, SqlMetric.id.in_(metrics_ids) + ) + ).all() + return len(metrics_ids) == len(dataset_query) @staticmethod - def delete(model: SqlaTable, commit=True): - try: - db.session.delete(model) - if commit: - db.session.commit() - except SQLAlchemyError as e: # pragma: no cover - logger.error(f"Failed to delete dataset: {e}") - db.session.rollback() - raise DeleteFailedError(exception=e) - return model + def validate_metrics_uniqueness(dataset_id: int, metrics_names: List[str]) -> bool: + dataset_query = ( + db.session.query(SqlMetric.id).filter( + SqlMetric.table_id == dataset_id, + SqlMetric.metric_name.in_(metrics_names), + ) + ).all() + return len(dataset_query) == 0 + + @classmethod + def update( + cls, model: SqlaTable, properties: Dict, commit: bool = True + ) -> Optional[SqlaTable]: + """ + Updates a Dataset model on the metadata DB + """ + if "columns" in properties: + new_columns = list() + for column in properties.get("columns", []): + if column.get("id"): + column_obj = db.session.query(TableColumn).get(column.get("id")) + column_obj = DatasetDAO.update_column( + column_obj, column, commit=commit + ) + else: + column_obj = DatasetDAO.create_column(column, commit=commit) + new_columns.append(column_obj) + properties["columns"] = new_columns + + if "metrics" in properties: + new_metrics = list() + for metric in properties.get("metrics", []): + if metric.get("id"): + metric_obj = db.session.query(SqlMetric).get(metric.get("id")) + metric_obj = DatasetDAO.update_metric( + metric_obj, metric, commit=commit + ) + else: + metric_obj = DatasetDAO.create_metric(metric, commit=commit) + new_metrics.append(metric_obj) + properties["metrics"] = new_metrics + + return super().update(model, properties, commit=commit) + + @classmethod + def update_column( + cls, model: TableColumn, properties: Dict, commit: bool = True + ) -> Optional[TableColumn]: + return DatasetColumnDAO.update(model, properties, commit=commit) + + @classmethod + def create_column( + cls, properties: Dict, commit: bool = True + ) -> Optional[TableColumn]: + """ + Creates a Dataset model on the metadata DB + """ + return DatasetColumnDAO.create(properties, commit=commit) + + @classmethod + def update_metric( + cls, model: SqlMetric, properties: Dict, commit: bool = True + ) -> Optional[SqlMetric]: + return DatasetMetricDAO.update(model, properties, commit=commit) + + @classmethod + def create_metric( + cls, properties: Dict, commit: bool = True + ) -> Optional[SqlMetric]: + """ + Creates a Dataset model on the metadata DB + """ + return DatasetMetricDAO.create(properties, commit=commit) + + +class DatasetColumnDAO(BaseDAO): + model_cls = TableColumn + + +class DatasetMetricDAO(BaseDAO): + model_cls = SqlMetric diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py index 370550da619c..7fac3592de49 100644 --- a/superset/datasets/schemas.py +++ b/superset/datasets/schemas.py @@ -14,10 +14,55 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import re -from marshmallow import fields, Schema +from flask_babel import lazy_gettext as _ +from marshmallow import fields, Schema, ValidationError from marshmallow.validate import Length +get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} + + +def validate_python_date_format(value: str) -> None: + regex = re.compile( + r""" + ^( + epoch_s|epoch_ms| + (?P<date>%Y(-%m(-%d)?)?)([\sT](?P<time>%H(:%M(:%S(\.%f)?)?)?))? + )$ + """, + re.VERBOSE, + ) + match = regex.match(value or "") + if not match: + raise ValidationError(_("Invalid date/timestamp format")) + + +class DatasetColumnsPutSchema(Schema): + id = fields.Integer() + column_name = fields.String(required=True, validate=Length(1, 255)) + type = fields.String(validate=Length(1, 32)) + verbose_name = fields.String(allow_none=True, Length=(1, 1024)) + description = fields.String(allow_none=True) + expression = fields.String(allow_none=True) + filterable = fields.Boolean() + groupby = fields.Boolean() + is_active = fields.Boolean() + is_dttm = fields.Boolean(default=False) + python_date_format = fields.String( + allow_none=True, validate=[Length(1, 255), validate_python_date_format] + ) + + +class DatasetMetricsPutSchema(Schema): + id = fields.Integer() + expression = fields.String(required=True) + description = fields.String(allow_none=True) + metric_name = fields.String(required=True, validate=Length(1, 255)) + metric_type = fields.String(allow_none=True, validate=Length(1, 32)) + d3format = fields.String(allow_none=True, validate=Length(1, 128)) + warning_text = fields.String(allow_none=True) + class DatasetPostSchema(Schema): database = fields.Integer(required=True) @@ -31,7 +76,7 @@ class DatasetPutSchema(Schema): sql = fields.String(allow_none=True) filter_select_enabled = fields.Boolean(allow_none=True) fetch_values_predicate = fields.String(allow_none=True, validate=Length(0, 1000)) - schema = fields.String(allow_none=True, validate=Length(1, 255)) + schema = fields.String(allow_none=True, validate=Length(0, 255)) description = fields.String(allow_none=True) main_dttm_col = fields.String(allow_none=True) offset = fields.Integer(allow_none=True) @@ -40,3 +85,5 @@ class DatasetPutSchema(Schema): is_sqllab_view = fields.Boolean(allow_none=True) template_params = fields.String(allow_none=True) owners = fields.List(fields.Integer()) + columns = fields.List(fields.Nested(DatasetColumnsPutSchema)) + metrics = fields.List(fields.Nested(DatasetMetricsPutSchema)) diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index b2ede28e2421..98b5eef300f2 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -16,6 +16,8 @@ # under the License. # pylint: disable=unused-argument import hashlib +import json +import logging import os import re from contextlib import closing @@ -59,6 +61,8 @@ ) from superset.models.core import Database # pylint: disable=unused-import +logger = logging.getLogger() + class TimeGrain(NamedTuple): # pylint: disable=too-few-public-methods name: str # TODO: redundant field, remove @@ -437,9 +441,7 @@ def csv_to_df(**kwargs: Any) -> pd.DataFrame: return df @classmethod - def df_to_sql( # pylint: disable=invalid-name - cls, df: pd.DataFrame, **kwargs: Any - ) -> None: + def df_to_sql(cls, df: pd.DataFrame, **kwargs: Any) -> None: """ Upload data from a Pandas DataFrame to a database. For regular engines this calls the DataFrame.to_sql() method. Can be overridden for engines that don't work well with to_sql(), e.g. @@ -558,13 +560,13 @@ def handle_cursor(cls, cursor: Any, query: Query, session: Session) -> None: pass @classmethod - def extract_error_message(cls, e: Exception) -> str: - return f"{cls.engine} error: {cls._extract_error_message(e)}" + def extract_error_message(cls, ex: Exception) -> str: + return f"{cls.engine} error: {cls._extract_error_message(ex)}" @classmethod - def _extract_error_message(cls, e: Exception) -> Optional[str]: + def _extract_error_message(cls, ex: Exception) -> Optional[str]: """Extract error message for queries""" - return utils.error_msg_from_exception(e) + return utils.error_msg_from_exception(ex) @classmethod def adjust_database_uri(cls, uri: URL, selected_schema: Optional[str]) -> None: @@ -959,3 +961,21 @@ def mutate_db_for_connection_test(database: "Database") -> None: :param database: instance to be mutated """ return None + + @staticmethod + def get_extra_params(database: "Database") -> Dict[str, Any]: + """ + Some databases require adding elements to connection parameters, + like passing certificates to `extra`. This can be done here. + + :param database: database instance from which to extract extras + :raises CertificateException: If certificate is not valid/unparseable + """ + extra: Dict[str, Any] = {} + if database.extra: + try: + extra = json.loads(database.extra) + except json.JSONDecodeError as ex: + logger.error(ex) + raise ex + return extra diff --git a/superset/db_engine_specs/druid.py b/superset/db_engine_specs/druid.py index e08bbdd4a34a..ab4e36a522a7 100644 --- a/superset/db_engine_specs/druid.py +++ b/superset/db_engine_specs/druid.py @@ -14,14 +14,20 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from typing import TYPE_CHECKING +import json +import logging +from typing import Any, Dict, TYPE_CHECKING from superset.db_engine_specs.base import BaseEngineSpec +from superset.utils import core as utils if TYPE_CHECKING: from superset.connectors.sqla.models import ( # pylint: disable=unused-import TableColumn, ) + from superset.models.core import Database # pylint: disable=unused-import + +logger = logging.getLogger() class DruidEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method @@ -47,3 +53,27 @@ class DruidEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method def alter_new_orm_column(cls, orm_col: "TableColumn") -> None: if orm_col.column_name == "__time": orm_col.is_dttm = True + + @staticmethod + def get_extra_params(database: "Database") -> Dict[str, Any]: + """ + For Druid, the path to a SSL certificate is placed in `connect_args`. + + :param database: database instance from which to extract extras + :raises CertificateException: If certificate is not valid/unparseable + """ + try: + extra = json.loads(database.extra or "{}") + except json.JSONDecodeError as ex: + logger.error(ex) + raise ex + + if database.server_cert: + engine_params = extra.get("engine_params", {}) + connect_args = engine_params.get("connect_args", {}) + connect_args["scheme"] = "https" + path = utils.create_ssl_cert_file(database.server_cert) + connect_args["ssl_verify_cert"] = path + engine_params["connect_args"] = connect_args + extra["engine_params"] = engine_params + return extra diff --git a/superset/db_engine_specs/hive.py b/superset/db_engine_specs/hive.py index 82d20d7cea90..b6240df6e783 100644 --- a/superset/db_engine_specs/hive.py +++ b/superset/db_engine_specs/hive.py @@ -203,8 +203,8 @@ def adjust_database_uri( uri.database = parse.quote(selected_schema, safe="") @classmethod - def _extract_error_message(cls, e: Exception) -> str: - msg = str(e) + def _extract_error_message(cls, ex: Exception) -> str: + msg = str(ex) match = re.search(r'errorMessage="(.*?)(?<!\\)"', msg) if match: msg = match.group(1) diff --git a/superset/db_engine_specs/mysql.py b/superset/db_engine_specs/mysql.py index b19527f7a2ee..abd7bd015ece 100644 --- a/superset/db_engine_specs/mysql.py +++ b/superset/db_engine_specs/mysql.py @@ -86,12 +86,12 @@ def epoch_to_dttm(cls) -> str: return "from_unixtime({col})" @classmethod - def _extract_error_message(cls, e: Exception) -> str: + def _extract_error_message(cls, ex: Exception) -> str: """Extract error message for queries""" - message = str(e) + message = str(ex) try: - if isinstance(e.args, tuple) and len(e.args) > 1: - message = e.args[1] + if isinstance(ex.args, tuple) and len(ex.args) > 1: + message = ex.args[1] except Exception: # pylint: disable=broad-except pass return message diff --git a/superset/db_engine_specs/presto.py b/superset/db_engine_specs/presto.py index 038b25b8cace..9bc9307ea7a5 100644 --- a/superset/db_engine_specs/presto.py +++ b/superset/db_engine_specs/presto.py @@ -762,22 +762,22 @@ def handle_cursor(cls, cursor: Any, query: Query, session: Session) -> None: polled = cursor.poll() @classmethod - def _extract_error_message(cls, e: Exception) -> Optional[str]: + def _extract_error_message(cls, ex: Exception) -> Optional[str]: if ( - hasattr(e, "orig") - and type(e.orig).__name__ == "DatabaseError" # type: ignore - and isinstance(e.orig[0], dict) # type: ignore + hasattr(ex, "orig") + and type(ex.orig).__name__ == "DatabaseError" # type: ignore + and isinstance(ex.orig[0], dict) # type: ignore ): - error_dict = e.orig[0] # type: ignore + error_dict = ex.orig[0] # type: ignore return "{} at {}: {}".format( error_dict.get("errorName"), error_dict.get("errorLocation"), error_dict.get("message"), ) - if type(e).__name__ == "DatabaseError" and hasattr(e, "args") and e.args: - error_dict = e.args[0] + if type(ex).__name__ == "DatabaseError" and hasattr(ex, "args") and ex.args: + error_dict = ex.args[0] return error_dict.get("message") - return utils.error_msg_from_exception(e) + return utils.error_msg_from_exception(ex) @classmethod def _partition_query( # pylint: disable=too-many-arguments,too-many-locals @@ -863,9 +863,7 @@ def where_latest_partition( # pylint: disable=too-many-arguments return query @classmethod - def _latest_partition_from_df( # pylint: disable=invalid-name - cls, df: pd.DataFrame - ) -> Optional[List[str]]: + def _latest_partition_from_df(cls, df: pd.DataFrame) -> Optional[List[str]]: if not df.empty: return df.to_records(index=False)[0].item() return None diff --git a/superset/db_engines/hive.py b/superset/db_engines/hive.py index 093b5ebb05bb..25f71b4cd28c 100644 --- a/superset/db_engines/hive.py +++ b/superset/db_engines/hive.py @@ -14,12 +14,19 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from pyhive.hive import Cursor # pylint: disable=unused-import + from TCLIService.ttypes import TFetchOrientation # pylint: disable=unused-import # pylint: disable=protected-access # TODO: contribute back to pyhive. def fetch_logs( - self, max_rows=1024, orientation=None -): # pylint: disable=unused-argument + self: "Cursor", + max_rows: int = 1024, # pylint: disable=unused-argument + orientation: Optional["TFetchOrientation"] = None, +) -> str: # pylint: disable=unused-argument """Mocked. Retrieve the logs produced by the execution of the query. Can be called multiple times to fetch the logs produced after the previous call. diff --git a/superset/examples/bart_lines.py b/superset/examples/bart_lines.py index 0181d1ea92d7..60b84b5e9043 100644 --- a/superset/examples/bart_lines.py +++ b/superset/examples/bart_lines.py @@ -26,7 +26,7 @@ from .helpers import get_example_data, TBL -def load_bart_lines(only_metadata=False, force=False): +def load_bart_lines(only_metadata: bool = False, force: bool = False) -> None: tbl_name = "bart_lines" database = get_example_database() table_exists = database.has_table_by_name(tbl_name) diff --git a/superset/examples/birth_names.py b/superset/examples/birth_names.py index e20c235dd026..58657c62cc2c 100644 --- a/superset/examples/birth_names.py +++ b/superset/examples/birth_names.py @@ -16,6 +16,7 @@ # under the License. import json import textwrap +from typing import Dict, Union import pandas as pd from sqlalchemy import DateTime, String @@ -23,6 +24,7 @@ from superset import db, security_manager from superset.connectors.sqla.models import SqlMetric, TableColumn +from superset.models.core import Database from superset.models.dashboard import Dashboard from superset.models.slice import Slice from superset.utils.core import get_example_database @@ -38,7 +40,9 @@ ) -def gen_filter(subject, comparator, operator="=="): +def gen_filter( + subject: str, comparator: str, operator: str = "==" +) -> Dict[str, Union[bool, str]]: return { "clause": "WHERE", "comparator": comparator, @@ -49,7 +53,7 @@ def gen_filter(subject, comparator, operator="=="): } -def load_data(tbl_name, database): +def load_data(tbl_name: str, database: Database) -> None: pdf = pd.read_json(get_example_data("birth_names.json.gz")) pdf.ds = pd.to_datetime(pdf.ds, unit="ms") pdf.to_sql( @@ -69,7 +73,7 @@ def load_data(tbl_name, database): print("-" * 80) -def load_birth_names(only_metadata=False, force=False): +def load_birth_names(only_metadata: bool = False, force: bool = False) -> None: """Loading birth name dataset from a zip file in the repo""" # pylint: disable=too-many-locals tbl_name = "birth_names" @@ -185,7 +189,7 @@ def load_birth_names(only_metadata=False, force=False): "expressionType": "SIMPLE", "filterOptionName": "2745eae5", "comparator": ["other"], - "operator": "not in", + "operator": "NOT IN", "subject": "state", } ], diff --git a/superset/examples/countries.py b/superset/examples/countries.py index 2bc352901d3f..97238d8c7737 100644 --- a/superset/examples/countries.py +++ b/superset/examples/countries.py @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. """This module contains data related to countries and is used for geo mapping""" -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional countries: List[Dict[str, Any]] = [ { @@ -2498,13 +2498,8 @@ all_lookups[lookup][country[lookup].lower()] = country -def get(field, symbol): +def get(field: str, symbol: str) -> Optional[Dict[str, Any]]: """ Get country data based on a standard code and a symbol - - >>> get('cioc', 'CUB')['name'] - "Cuba" - >>> get('cca2', 'CA')['name'] - "Canada" """ return all_lookups[field].get(symbol.lower()) diff --git a/superset/examples/country_map.py b/superset/examples/country_map.py index d77eb955a7c7..e9966ff305a4 100644 --- a/superset/examples/country_map.py +++ b/superset/examples/country_map.py @@ -34,7 +34,7 @@ ) -def load_country_map_data(only_metadata=False, force=False): +def load_country_map_data(only_metadata: bool = False, force: bool = False) -> None: """Loading data for map with country map""" tbl_name = "birth_france_by_region" database = utils.get_example_database() diff --git a/superset/examples/css_templates.py b/superset/examples/css_templates.py index be677055ec40..4f3f355895ef 100644 --- a/superset/examples/css_templates.py +++ b/superset/examples/css_templates.py @@ -20,7 +20,7 @@ from superset.models.core import CssTemplate -def load_css_templates(): +def load_css_templates() -> None: """Loads 2 css templates to demonstrate the feature""" print("Creating default CSS templates") diff --git a/superset/examples/deck.py b/superset/examples/deck.py index bce4df3be5e8..b9b85ee1faab 100644 --- a/superset/examples/deck.py +++ b/superset/examples/deck.py @@ -167,7 +167,7 @@ }""" -def load_deck_dash(): +def load_deck_dash() -> None: print("Loading deck.gl dashboard") slices = [] tbl = db.session.query(TBL).filter_by(table_name="long_lat").first() diff --git a/superset/examples/energy.py b/superset/examples/energy.py index c3c33bd37f43..b27c45ebb523 100644 --- a/superset/examples/energy.py +++ b/superset/examples/energy.py @@ -29,7 +29,7 @@ from .helpers import get_example_data, merge_slice, misc_dash_slices, TBL -def load_energy(only_metadata=False, force=False): +def load_energy(only_metadata: bool = False, force: bool = False) -> None: """Loads an energy related dataset to use with sankey and graphs""" tbl_name = "energy_usage" database = utils.get_example_database() diff --git a/superset/examples/flights.py b/superset/examples/flights.py index e4db4ca0c429..16f88491dd34 100644 --- a/superset/examples/flights.py +++ b/superset/examples/flights.py @@ -23,7 +23,7 @@ from .helpers import get_example_data, TBL -def load_flights(only_metadata=False, force=False): +def load_flights(only_metadata: bool = False, force: bool = False) -> None: """Loading random time series data from a zip file in the repo""" tbl_name = "flights" database = utils.get_example_database() diff --git a/superset/examples/helpers.py b/superset/examples/helpers.py index 5fac6a0d61e0..58f8de293d16 100644 --- a/superset/examples/helpers.py +++ b/superset/examples/helpers.py @@ -19,7 +19,7 @@ import os import zlib from io import BytesIO -from typing import Set +from typing import Any, Dict, List, Set from urllib import request from superset import app, db @@ -41,7 +41,7 @@ misc_dash_slices: Set[str] = set() # slices assembled in a 'Misc Chart' dashboard -def update_slice_ids(layout_dict, slices): +def update_slice_ids(layout_dict: Dict[Any, Any], slices: List[Slice]) -> None: charts = [ component for component in layout_dict.values() @@ -53,7 +53,7 @@ def update_slice_ids(layout_dict, slices): chart_component["meta"]["chartId"] = int(slices[i].id) -def merge_slice(slc): +def merge_slice(slc: Slice) -> None: o = db.session.query(Slice).filter_by(slice_name=slc.slice_name).first() if o: db.session.delete(o) @@ -61,13 +61,15 @@ def merge_slice(slc): db.session.commit() -def get_slice_json(defaults, **kwargs): - d = defaults.copy() - d.update(kwargs) - return json.dumps(d, indent=4, sort_keys=True) +def get_slice_json(defaults: Dict[Any, Any], **kwargs: Any) -> str: + defaults_copy = defaults.copy() + defaults_copy.update(kwargs) + return json.dumps(defaults_copy, indent=4, sort_keys=True) -def get_example_data(filepath, is_gzip=True, make_bytes=False): +def get_example_data( + filepath: str, is_gzip: bool = True, make_bytes: bool = False +) -> BytesIO: content = request.urlopen(f"{BASE_URL}{filepath}?raw=true").read() if is_gzip: content = zlib.decompress(content, zlib.MAX_WBITS | 16) diff --git a/superset/examples/long_lat.py b/superset/examples/long_lat.py index d90b8b485a7a..55fce5b1eb22 100644 --- a/superset/examples/long_lat.py +++ b/superset/examples/long_lat.py @@ -34,7 +34,7 @@ ) -def load_long_lat_data(only_metadata=False, force=False): +def load_long_lat_data(only_metadata: bool = False, force: bool = False) -> None: """Loading lat/long data from a csv file in the repo""" tbl_name = "long_lat" database = utils.get_example_database() diff --git a/superset/examples/misc_dashboard.py b/superset/examples/misc_dashboard.py index 2c90dbbf3a82..a8f282ee431c 100644 --- a/superset/examples/misc_dashboard.py +++ b/superset/examples/misc_dashboard.py @@ -26,7 +26,7 @@ DASH_SLUG = "misc_charts" -def load_misc_dashboard(): +def load_misc_dashboard() -> None: """Loading a dashboard featuring misc charts""" print("Creating the dashboard") diff --git a/superset/examples/multi_line.py b/superset/examples/multi_line.py index b04db8a62089..1887fd09069e 100644 --- a/superset/examples/multi_line.py +++ b/superset/examples/multi_line.py @@ -24,7 +24,7 @@ from .world_bank import load_world_bank_health_n_pop -def load_multi_line(only_metadata=False): +def load_multi_line(only_metadata: bool = False) -> None: load_world_bank_health_n_pop(only_metadata) load_birth_names(only_metadata) ids = [ diff --git a/superset/examples/multiformat_time_series.py b/superset/examples/multiformat_time_series.py index 97a7d9566cf7..dfc36eed65c2 100644 --- a/superset/examples/multiformat_time_series.py +++ b/superset/examples/multiformat_time_series.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Dict, Optional, Tuple import pandas as pd from sqlalchemy import BigInteger, Date, DateTime, String @@ -32,7 +33,9 @@ ) -def load_multiformat_time_series(only_metadata=False, force=False): +def load_multiformat_time_series( + only_metadata: bool = False, force: bool = False +) -> None: """Loading time series data from a zip file in the repo""" tbl_name = "multiformat_time_series" database = get_example_database() @@ -70,15 +73,15 @@ def load_multiformat_time_series(only_metadata=False, force=False): obj = TBL(table_name=tbl_name) obj.main_dttm_col = "ds" obj.database = database - dttm_and_expr_dict = { - "ds": [None, None], - "ds2": [None, None], - "epoch_s": ["epoch_s", None], - "epoch_ms": ["epoch_ms", None], - "string2": ["%Y%m%d-%H%M%S", None], - "string1": ["%Y-%m-%d^%H:%M:%S", None], - "string0": ["%Y-%m-%d %H:%M:%S.%f", None], - "string3": ["%Y/%m/%d%H:%M:%S.%f", None], + dttm_and_expr_dict: Dict[str, Tuple[Optional[str], None]] = { + "ds": (None, None), + "ds2": (None, None), + "epoch_s": ("epoch_s", None), + "epoch_ms": ("epoch_ms", None), + "string2": ("%Y%m%d-%H%M%S", None), + "string1": ("%Y-%m-%d^%H:%M:%S", None), + "string0": ("%Y-%m-%d %H:%M:%S.%f", None), + "string3": ("%Y/%m/%d%H:%M:%S.%f", None), } for col in obj.columns: dttm_and_expr = dttm_and_expr_dict[col.column_name] diff --git a/superset/examples/paris.py b/superset/examples/paris.py index 482ada6115e6..a9ea87184615 100644 --- a/superset/examples/paris.py +++ b/superset/examples/paris.py @@ -25,7 +25,7 @@ from .helpers import get_example_data, TBL -def load_paris_iris_geojson(only_metadata=False, force=False): +def load_paris_iris_geojson(only_metadata: bool = False, force: bool = False) -> None: tbl_name = "paris_iris_mapping" database = utils.get_example_database() table_exists = database.has_table_by_name(tbl_name) diff --git a/superset/examples/random_time_series.py b/superset/examples/random_time_series.py index 151d04c7f4a2..41eb98e2c0c5 100644 --- a/superset/examples/random_time_series.py +++ b/superset/examples/random_time_series.py @@ -25,7 +25,9 @@ from .helpers import config, get_example_data, get_slice_json, merge_slice, TBL -def load_random_time_series_data(only_metadata=False, force=False): +def load_random_time_series_data( + only_metadata: bool = False, force: bool = False +) -> None: """Loading random time series data from a zip file in the repo""" tbl_name = "random_time_series" database = utils.get_example_database() @@ -60,8 +62,8 @@ def load_random_time_series_data(only_metadata=False, force=False): slice_data = { "granularity_sqla": "day", "row_limit": config["ROW_LIMIT"], - "since": "1 year ago", - "until": "now", + "since": "2019-01-01", + "until": "2019-02-01", "metric": "count", "viz_type": "cal_heatmap", "domain_granularity": "month", diff --git a/superset/examples/sf_population_polygons.py b/superset/examples/sf_population_polygons.py index a4281d9225bc..952bdccc5399 100644 --- a/superset/examples/sf_population_polygons.py +++ b/superset/examples/sf_population_polygons.py @@ -25,7 +25,9 @@ from .helpers import get_example_data, TBL -def load_sf_population_polygons(only_metadata=False, force=False): +def load_sf_population_polygons( + only_metadata: bool = False, force: bool = False +) -> None: tbl_name = "sf_population_polygons" database = utils.get_example_database() table_exists = database.has_table_by_name(tbl_name) diff --git a/superset/examples/tabbed_dashboard.py b/superset/examples/tabbed_dashboard.py index a35087767fe2..1eaed48f25e7 100644 --- a/superset/examples/tabbed_dashboard.py +++ b/superset/examples/tabbed_dashboard.py @@ -25,7 +25,7 @@ from .helpers import update_slice_ids -def load_tabbed_dashboard(_=False): +def load_tabbed_dashboard(_: bool = False) -> None: """Creating a tabbed dashboard""" print("Creating a dashboard with nested tabs") diff --git a/superset/examples/unicode_test_data.py b/superset/examples/unicode_test_data.py index d48dc34dac65..70d5e72d3ae9 100644 --- a/superset/examples/unicode_test_data.py +++ b/superset/examples/unicode_test_data.py @@ -36,7 +36,7 @@ ) -def load_unicode_test_data(only_metadata=False, force=False): +def load_unicode_test_data(only_metadata: bool = False, force: bool = False) -> None: """Loading unicode test dataset from a csv file in the repo""" tbl_name = "unicode_test" database = utils.get_example_database() diff --git a/superset/examples/world_bank.py b/superset/examples/world_bank.py index b30d07fe0f32..1764e112210e 100644 --- a/superset/examples/world_bank.py +++ b/superset/examples/world_bank.py @@ -41,9 +41,9 @@ ) -def load_world_bank_health_n_pop( - only_metadata=False, force=False -): # pylint: disable=too-many-locals +def load_world_bank_health_n_pop( # pylint: disable=too-many-locals + only_metadata: bool = False, force: bool = False +) -> None: """Loads the world bank health dataset, slices and a dashboard""" tbl_name = "wb_health_population" database = utils.get_example_database() @@ -249,7 +249,7 @@ def load_world_bank_health_n_pop( "AMA", "PLW", ], - "operator": "not in", + "operator": "NOT IN", "subject": "country_code", } ], diff --git a/superset/exceptions.py b/superset/exceptions.py index 605627c0efba..e7f2e2d400ef 100644 --- a/superset/exceptions.py +++ b/superset/exceptions.py @@ -16,6 +16,8 @@ # under the License. from typing import Optional +from flask_babel import gettext as _ + class SupersetException(Exception): status = 500 @@ -60,5 +62,13 @@ class SpatialException(SupersetException): pass +class CertificateException(SupersetException): + message = _("Invalid certificate") + + class DatabaseNotFound(SupersetException): status = 400 + + +class QueryObjectValidationError(SupersetException): + status = 400 diff --git a/superset/extensions.py b/superset/extensions.py index 0b7f39bae66f..c501eeb36ead 100644 --- a/superset/extensions.py +++ b/superset/extensions.py @@ -20,6 +20,7 @@ import time import uuid from datetime import datetime, timedelta +from typing import Dict, TYPE_CHECKING # pylint: disable=unused-import import celery from dateutil.relativedelta import relativedelta @@ -31,6 +32,12 @@ from superset.utils.cache_manager import CacheManager from superset.utils.feature_flag_manager import FeatureFlagManager +# Avoid circular import +if TYPE_CHECKING: + from superset.jinja_context import ( # pylint: disable=unused-import + BaseTemplateProcessor, + ) + class JinjaContextManager: def __init__(self) -> None: @@ -42,14 +49,20 @@ def __init__(self) -> None: "timedelta": timedelta, "uuid": uuid, } + self._template_processors = {} # type: Dict[str, BaseTemplateProcessor] def init_app(self, app): self._base_context.update(app.config["JINJA_CONTEXT_ADDONS"]) + self._template_processors.update(app.config["CUSTOM_TEMPLATE_PROCESSORS"]) @property def base_context(self): return self._base_context + @property + def template_processors(self): + return self._template_processors + class ResultsBackendManager: def __init__(self) -> None: @@ -120,7 +133,7 @@ def get_manifest_files(self, bundle, asset_type): _event_logger: dict = {} event_logger = LocalProxy(lambda: _event_logger.get("event_logger")) feature_flag_manager = FeatureFlagManager() -jinja_context_manager = JinjaContextManager() +jinja_context_manager = JinjaContextManager() # type: JinjaContextManager manifest_processor = UIManifestProcessor(APP_DIR) migrate = Migrate() results_backend_manager = ResultsBackendManager() diff --git a/superset/forms.py b/superset/forms.py index fd2d078f834b..175903af20b9 100644 --- a/superset/forms.py +++ b/superset/forms.py @@ -23,7 +23,7 @@ class CommaSeparatedListField(Field): widget = BS3TextFieldWidget() - data = [] # type: List[str] + data: List[str] = [] def _value(self): if self.data: diff --git a/superset/jinja_context.py b/superset/jinja_context.py index ebdd66579bcb..b4f915917588 100644 --- a/superset/jinja_context.py +++ b/superset/jinja_context.py @@ -23,6 +23,8 @@ from jinja2.sandbox import SandboxedEnvironment from superset import jinja_base_context +from superset.extensions import jinja_context_manager +from superset.utils.core import convert_legacy_filters_into_adhoc, merge_extra_filters def url_param(param: str, default: Optional[str] = None) -> Optional[Any]: @@ -79,8 +81,6 @@ def filter_values(column: str, default: Optional[str] = None) -> List[str]: - you want to have the ability for filter inside the main query for speed purposes - This searches for "filters" and "extra_filters" in ``form_data`` for a match - Usage example:: SELECT action, count(*) as times @@ -92,19 +92,26 @@ def filter_values(column: str, default: Optional[str] = None) -> List[str]: :param default: default value to return if there's no matching columns :return: returns a list of filter values """ + form_data = json.loads(request.form.get("form_data", "{}")) - return_val = [] - for filter_type in ["filters", "extra_filters"]: - if filter_type not in form_data: - continue - - for f in form_data[filter_type]: - if f["col"] == column: - if isinstance(f["val"], list): - for v in f["val"]: - return_val.append(v) - else: - return_val.append(f["val"]) + convert_legacy_filters_into_adhoc(form_data) + merge_extra_filters(form_data) + + return_val = [ + comparator + for filter in form_data.get("adhoc_filters", []) + for comparator in ( + filter["comparator"] + if isinstance(filter["comparator"], list) + else [filter["comparator"]] + ) + if ( + filter.get("expressionType") == "SIMPLE" + and filter.get("clause") == "WHERE" + and filter.get("subject") == column + and filter.get("comparator") + ) + ] if return_val: return return_val @@ -263,7 +270,8 @@ class HiveTemplateProcessor(PrestoTemplateProcessor): engine = "hive" -template_processors = {} +# The global template processors from Jinja context manager. +template_processors = jinja_context_manager.template_processors keys = tuple(globals().keys()) for k in keys: o = globals()[k] diff --git a/superset/migrations/env.py b/superset/migrations/env.py index 048b2b2107e9..37b0190e50b4 100755 --- a/superset/migrations/env.py +++ b/superset/migrations/env.py @@ -16,8 +16,11 @@ # under the License. import logging from logging.config import fileConfig +from typing import List from alembic import context +from alembic.operations.ops import MigrationScript +from alembic.runtime.migration import MigrationContext from flask import current_app from flask_appbuilder import Base from sqlalchemy import engine_from_config, pool @@ -41,7 +44,7 @@ # ... etc. -def run_migrations_offline(): +def run_migrations_offline() -> None: """Run migrations in 'offline' mode. This configures the context with just a URL @@ -60,7 +63,7 @@ def run_migrations_offline(): context.run_migrations() -def run_migrations_online(): +def run_migrations_online() -> None: """Run migrations in 'online' mode. In this scenario we need to create an Engine @@ -71,9 +74,9 @@ def run_migrations_online(): # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: https://alembic.sqlalchemy.org/en/latest/cookbook.html - def process_revision_directives( - context, revision, directives - ): # pylint: disable=redefined-outer-name, unused-argument + def process_revision_directives( # pylint: disable=redefined-outer-name, unused-argument + context: MigrationContext, revision: str, directives: List[MigrationScript] + ) -> None: if getattr(config.cmd_opts, "autogenerate", False): script = directives[0] if script.upgrade_ops.is_empty(): diff --git a/superset/migrations/versions/190188938582_adding_unique_constraint_on_dashboard_slices_tbl.py b/superset/migrations/versions/190188938582_adding_unique_constraint_on_dashboard_slices_tbl.py index a91b3da65e59..3cea2e032ced 100644 --- a/superset/migrations/versions/190188938582_adding_unique_constraint_on_dashboard_slices_tbl.py +++ b/superset/migrations/versions/190188938582_adding_unique_constraint_on_dashboard_slices_tbl.py @@ -88,13 +88,13 @@ def upgrade(): batch_op.create_unique_constraint( "uq_dashboard_slice", ["dashboard_id", "slice_id"] ) - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) def downgrade(): try: with op.batch_alter_table("dashboard_slices") as batch_op: batch_op.drop_constraint("uq_dashboard_slice", type_="unique") - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) diff --git a/superset/migrations/versions/3325d4caccc8_dashboard_scoped_filters.py b/superset/migrations/versions/3325d4caccc8_dashboard_scoped_filters.py index d3a96427cd58..5aa38fd13a48 100644 --- a/superset/migrations/versions/3325d4caccc8_dashboard_scoped_filters.py +++ b/superset/migrations/versions/3325d4caccc8_dashboard_scoped_filters.py @@ -101,8 +101,8 @@ def upgrade(): dashboard.json_metadata = None session.merge(dashboard) - except Exception as e: - logging.exception(f"dashboard {dashboard.id} has error: {e}") + except Exception as ex: + logging.exception(f"dashboard {dashboard.id} has error: {ex}") session.commit() session.close() diff --git a/superset/migrations/versions/3b626e2a6783_sync_db_with_models.py b/superset/migrations/versions/3b626e2a6783_sync_db_with_models.py index dd199468e5ef..b7e55974d3fa 100644 --- a/superset/migrations/versions/3b626e2a6783_sync_db_with_models.py +++ b/superset/migrations/versions/3b626e2a6783_sync_db_with_models.py @@ -58,8 +58,8 @@ def upgrade(): batch_op.drop_constraint(slices_ibfk_2, type_="foreignkey") batch_op.drop_column("druid_datasource_id") batch_op.drop_column("table_id") - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) # fixed issue: https://github.com/airbnb/superset/issues/466 try: @@ -67,27 +67,27 @@ def upgrade(): batch_op.create_foreign_key( None, "datasources", ["datasource_name"], ["datasource_name"] ) - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) try: with op.batch_alter_table("query") as batch_op: batch_op.create_unique_constraint("client_id", ["client_id"]) - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) try: with op.batch_alter_table("query") as batch_op: batch_op.drop_column("name") - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) def downgrade(): try: with op.batch_alter_table("tables") as batch_op: batch_op.create_index("table_name", ["table_name"], unique=True) - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) try: with op.batch_alter_table("slices") as batch_op: @@ -111,8 +111,8 @@ def downgrade(): "slices_ibfk_1", "datasources", ["druid_datasource_id"], ["id"] ) batch_op.create_foreign_key("slices_ibfk_2", "tables", ["table_id"], ["id"]) - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) try: fk_columns = generic_find_constraint_name( @@ -123,12 +123,12 @@ def downgrade(): ) with op.batch_alter_table("columns") as batch_op: batch_op.drop_constraint(fk_columns, type_="foreignkey") - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) op.add_column("query", sa.Column("name", sa.String(length=256), nullable=True)) try: with op.batch_alter_table("query") as batch_op: batch_op.drop_constraint("client_id", type_="unique") - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) diff --git a/superset/migrations/versions/4736ec66ce19_.py b/superset/migrations/versions/4736ec66ce19_.py index f2d04f9a3671..99b3e0b5e350 100644 --- a/superset/migrations/versions/4736ec66ce19_.py +++ b/superset/migrations/versions/4736ec66ce19_.py @@ -120,14 +120,14 @@ def upgrade(): or "uq_datasources_datasource_name", type_="unique", ) - except Exception as e: + except Exception as ex: logging.warning( "Constraint drop failed, you may want to do this " "manually on your database. For context, this is a known " "issue around undeterministic contraint names on Postgres " "and perhaps more databases through SQLAlchemy." ) - logging.exception(e) + logging.exception(ex) def downgrade(): diff --git a/superset/migrations/versions/65903709c321_allow_dml.py b/superset/migrations/versions/65903709c321_allow_dml.py index 0c72d418df43..6836e8ee2040 100644 --- a/superset/migrations/versions/65903709c321_allow_dml.py +++ b/superset/migrations/versions/65903709c321_allow_dml.py @@ -39,6 +39,6 @@ def upgrade(): def downgrade(): try: op.drop_column("dbs", "allow_dml") - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) pass diff --git a/superset/migrations/versions/80aa3f04bc82_add_parent_ids_in_dashboard_layout.py b/superset/migrations/versions/80aa3f04bc82_add_parent_ids_in_dashboard_layout.py index 1e29855ed3f5..c6361009ee4f 100644 --- a/superset/migrations/versions/80aa3f04bc82_add_parent_ids_in_dashboard_layout.py +++ b/superset/migrations/versions/80aa3f04bc82_add_parent_ids_in_dashboard_layout.py @@ -81,8 +81,8 @@ def upgrade(): layout, indent=None, separators=(",", ":"), sort_keys=True ) session.merge(dashboard) - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) session.commit() session.close() @@ -111,8 +111,8 @@ def downgrade(): layout, indent=None, separators=(",", ":"), sort_keys=True ) session.merge(dashboard) - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) session.commit() session.close() diff --git a/superset/migrations/versions/ab8c66efdd01_resample.py b/superset/migrations/versions/ab8c66efdd01_resample.py index aa7bf868f4a6..928636040598 100644 --- a/superset/migrations/versions/ab8c66efdd01_resample.py +++ b/superset/migrations/versions/ab8c66efdd01_resample.py @@ -85,8 +85,8 @@ def upgrade(): params.pop("resample_fillmethod", None) params.pop("resample_how", None) slc.params = json.dumps(params, sort_keys=True) - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) session.commit() session.close() @@ -110,8 +110,8 @@ def downgrade(): del params["resample_method"] slc.params = json.dumps(params, sort_keys=True) - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) session.commit() session.close() diff --git a/superset/migrations/versions/b46fa1b0b39e_add_params_to_tables.py b/superset/migrations/versions/b46fa1b0b39e_add_params_to_tables.py index 8bc309cc30f3..97e58b1735d5 100644 --- a/superset/migrations/versions/b46fa1b0b39e_add_params_to_tables.py +++ b/superset/migrations/versions/b46fa1b0b39e_add_params_to_tables.py @@ -39,5 +39,5 @@ def upgrade(): def downgrade(): try: op.drop_column("tables", "params") - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) diff --git a/superset/migrations/versions/b5998378c225_add_certificate_to_dbs.py b/superset/migrations/versions/b5998378c225_add_certificate_to_dbs.py new file mode 100644 index 000000000000..70799579d942 --- /dev/null +++ b/superset/migrations/versions/b5998378c225_add_certificate_to_dbs.py @@ -0,0 +1,47 @@ +# 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. +"""add certificate to dbs + +Revision ID: b5998378c225 +Revises: 72428d1ea401 +Create Date: 2020-03-25 10:49:10.883065 + +""" + +# revision identifiers, used by Alembic. +revision = "b5998378c225" +down_revision = "72428d1ea401" + +from typing import Dict + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql.base import PGDialect +from sqlalchemy_utils import EncryptedType + + +def upgrade(): + kwargs: Dict[str, str] = {} + bind = op.get_bind() + op.add_column( + "dbs", + sa.Column("server_cert", EncryptedType(sa.Text()), nullable=True, **kwargs), + ) + + +def downgrade(): + op.drop_column("dbs", "server_cert") diff --git a/superset/migrations/versions/bf706ae5eb46_cal_heatmap_metric_to_metrics.py b/superset/migrations/versions/bf706ae5eb46_cal_heatmap_metric_to_metrics.py index 9b936c9112ae..3e2b81c17a82 100644 --- a/superset/migrations/versions/bf706ae5eb46_cal_heatmap_metric_to_metrics.py +++ b/superset/migrations/versions/bf706ae5eb46_cal_heatmap_metric_to_metrics.py @@ -62,8 +62,8 @@ def upgrade(): session.merge(slc) session.commit() print("Upgraded ({}/{}): {}".format(i, slice_len, slc.slice_name)) - except Exception as e: - print(slc.slice_name + " error: " + str(e)) + except Exception as ex: + print(slc.slice_name + " error: " + str(ex)) session.close() diff --git a/superset/migrations/versions/db0c65b146bd_update_slice_model_json.py b/superset/migrations/versions/db0c65b146bd_update_slice_model_json.py index f6ed41b2e695..56d5f887b3e0 100644 --- a/superset/migrations/versions/db0c65b146bd_update_slice_model_json.py +++ b/superset/migrations/versions/db0c65b146bd_update_slice_model_json.py @@ -60,8 +60,8 @@ def upgrade(): session.merge(slc) session.commit() print("Upgraded ({}/{}): {}".format(i, slice_len, slc.slice_name)) - except Exception as e: - print(slc.slice_name + " error: " + str(e)) + except Exception as ex: + print(slc.slice_name + " error: " + str(ex)) session.close() diff --git a/superset/migrations/versions/db527d8c4c78_add_db_verbose_name.py b/superset/migrations/versions/db527d8c4c78_add_db_verbose_name.py index 30bc9817a705..0cb9c94b5663 100644 --- a/superset/migrations/versions/db527d8c4c78_add_db_verbose_name.py +++ b/superset/migrations/versions/db527d8c4c78_add_db_verbose_name.py @@ -43,7 +43,7 @@ def upgrade(): try: op.create_unique_constraint(None, "dbs", ["verbose_name"]) op.create_unique_constraint(None, "clusters", ["verbose_name"]) - except Exception as e: + except Exception: logging.info("Constraint not created, expected when using sqlite") @@ -51,5 +51,5 @@ def downgrade(): try: op.drop_column("dbs", "verbose_name") op.drop_column("clusters", "verbose_name") - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) diff --git a/superset/migrations/versions/e502db2af7be_add_template_params_to_tables.py b/superset/migrations/versions/e502db2af7be_add_template_params_to_tables.py index c2bb2ec0ff36..b76ea623822c 100644 --- a/superset/migrations/versions/e502db2af7be_add_template_params_to_tables.py +++ b/superset/migrations/versions/e502db2af7be_add_template_params_to_tables.py @@ -37,5 +37,5 @@ def upgrade(): def downgrade(): try: op.drop_column("tables", "template_params") - except Exception as e: - logging.warning(str(e)) + except Exception as ex: + logging.warning(str(ex)) diff --git a/superset/migrations/versions/f9a30386bd74_cleanup_time_grainularity.py b/superset/migrations/versions/f9a30386bd74_cleanup_time_grainularity.py new file mode 100644 index 000000000000..675cdfa2c2e4 --- /dev/null +++ b/superset/migrations/versions/f9a30386bd74_cleanup_time_grainularity.py @@ -0,0 +1,98 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""cleanup_time_grainularity + +Revision ID: f9a30386bd74 +Revises: b5998378c225 +Create Date: 2020-03-25 10:42:11.047328 + +""" + +# revision identifiers, used by Alembic. +revision = "f9a30386bd74" +down_revision = "b5998378c225" + +import json + +from alembic import op +from sqlalchemy import Column, Integer, String, Text +from sqlalchemy.ext.declarative import declarative_base + +from superset import db + +Base = declarative_base() + + +class Slice(Base): + __tablename__ = "slices" + + id = Column(Integer, primary_key=True) + params = Column(Text) + viz_type = Column(String(250)) + + +def upgrade(): + """ + Remove any erroneous time grainularity fields from slices foor those visualization + types which do not support time granularity. + + :see: https://github.com/apache/incubator-superset/pull/8674 + :see: https://github.com/apache/incubator-superset/pull/8764 + :see: https://github.com/apache/incubator-superset/pull/8800 + :see: https://github.com/apache/incubator-superset/pull/8825 + """ + + bind = op.get_bind() + session = db.Session(bind=bind) + + # Visualization types which support time grainularity (hence negate). + viz_types = [ + "area", + "bar", + "big_number", + "compare", + "dual_line", + "line", + "pivot_table", + "table", + "time_pivot", + "time_table", + ] + + # Erroneous time grainularity fields for either Druid NoSQL or SQL slices which do + # not support time grainularity. + erroneous = ["grainularity", "time_grain_sqla"] + + for slc in session.query(Slice).filter(Slice.viz_type.notin_(viz_types)).all(): + try: + params = json.loads(slc.params) + + if any(field in params for field in erroneous): + for field in erroneous: + if field in params: + del params[field] + + slc.params = json.dumps(params, sort_keys=True) + except Exception: + pass + + session.commit() + session.close() + + +def downgrade(): + pass diff --git a/superset/migrations/versions/fb13d49b72f9_better_filters.py b/superset/migrations/versions/fb13d49b72f9_better_filters.py index 97564e82a13a..2a58fdf21c01 100644 --- a/superset/migrations/versions/fb13d49b72f9_better_filters.py +++ b/superset/migrations/versions/fb13d49b72f9_better_filters.py @@ -78,7 +78,7 @@ def upgrade(): for slc in filter_box_slices.all(): try: upgrade_slice(slc) - except Exception as e: + except Exception as ex: logging.exception(e) session.commit() @@ -100,8 +100,8 @@ def downgrade(): params["metric"] = flts[0].get("metric") params["groupby"] = [o.get("column") for o in flts] slc.params = json.dumps(params, sort_keys=True) - except Exception as e: - logging.exception(e) + except Exception as ex: + logging.exception(ex) session.commit() session.close() diff --git a/superset/models/annotations.py b/superset/models/annotations.py index 33197ddd5c30..07e23517486a 100644 --- a/superset/models/annotations.py +++ b/superset/models/annotations.py @@ -27,7 +27,7 @@ class AnnotationLayer(Model, AuditMixinNullable): """A logical namespace for a set of annotations""" __tablename__ = "annotation_layer" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) name = Column(String(250)) descr = Column(Text) @@ -40,7 +40,7 @@ class Annotation(Model, AuditMixinNullable): """Time-related annotation""" __tablename__ = "annotation" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) start_dttm = Column(DateTime) end_dttm = Column(DateTime) layer_id = Column(Integer, ForeignKey("annotation_layer.id"), nullable=False) diff --git a/superset/models/core.py b/superset/models/core.py index b5f94fd87632..10609860e990 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -73,7 +73,7 @@ class Url(Model, AuditMixinNullable): """Used for the short url feature""" __tablename__ = "url" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) url = Column(Text) @@ -82,7 +82,7 @@ class KeyValue(Model): # pylint: disable=too-few-public-methods """Used for any type of key-value store""" __tablename__ = "keyvalue" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) value = Column(Text, nullable=False) @@ -91,7 +91,7 @@ class CssTemplate(Model, AuditMixinNullable): """CSS templates for dashboards""" __tablename__ = "css_templates" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) template_name = Column(String(250)) css = Column(Text, default="") @@ -106,7 +106,7 @@ class Database( type = "table" __table_args__ = (UniqueConstraint("database_name"),) - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) verbose_name = Column(String(250), unique=True) # short unique name, used in permissions database_name = Column(String(250), unique=True, nullable=False) @@ -139,6 +139,7 @@ class Database( encrypted_extra = Column(EncryptedType(Text, config["SECRET_KEY"]), nullable=True) perm = Column(String(1000)) impersonate_user = Column(Boolean, default=False) + server_cert = Column(EncryptedType(Text, config["SECRET_KEY"]), nullable=True) export_fields = [ "database_name", "sqlalchemy_uri", @@ -309,6 +310,7 @@ def get_sqla_engine( ) if configuration: connect_args["configuration"] = configuration + if connect_args: params["connect_args"] = connect_args params.update(self.get_encrypted_extra()) @@ -458,7 +460,7 @@ def get_all_table_names_in_schema( self, schema: str, cache: bool = False, - cache_timeout: int = None, + cache_timeout: Optional[int] = None, force: bool = False, ) -> List[utils.DatasourceName]: """Parameters need to be passed as keyword arguments. @@ -479,8 +481,8 @@ def get_all_table_names_in_schema( return [ utils.DatasourceName(table=table, schema=schema) for table in tables ] - except Exception as e: # pylint: disable=broad-except - logger.exception(e) + except Exception as ex: # pylint: disable=broad-except + logger.exception(ex) @cache_util.memoized_func( key=lambda *args, **kwargs: f"db:{{}}:schema:{kwargs.get('schema')}:view_list", # type: ignore @@ -490,7 +492,7 @@ def get_all_view_names_in_schema( self, schema: str, cache: bool = False, - cache_timeout: int = None, + cache_timeout: Optional[int] = None, force: bool = False, ) -> List[utils.DatasourceName]: """Parameters need to be passed as keyword arguments. @@ -509,8 +511,8 @@ def get_all_view_names_in_schema( database=self, inspector=self.inspector, schema=schema ) return [utils.DatasourceName(table=view, schema=schema) for view in views] - except Exception as e: # pylint: disable=broad-except - logger.exception(e) + except Exception as ex: # pylint: disable=broad-except + logger.exception(ex) @cache_util.memoized_func( key=lambda *args, **kwargs: "db:{}:schema_list", attribute_in_key="id" @@ -555,23 +557,16 @@ def grains(self) -> Tuple[TimeGrain, ...]: return self.db_engine_spec.get_time_grains() def get_extra(self) -> Dict[str, Any]: - extra: Dict[str, Any] = {} - if self.extra: - try: - extra = json.loads(self.extra) - except json.JSONDecodeError as e: - logger.error(e) - raise e - return extra + return self.db_engine_spec.get_extra_params(self) def get_encrypted_extra(self): encrypted_extra = {} if self.encrypted_extra: try: encrypted_extra = json.loads(self.encrypted_extra) - except json.JSONDecodeError as e: - logger.error(e) - raise e + except json.JSONDecodeError as ex: + logger.error(ex) + raise ex return encrypted_extra def get_table(self, table_name: str, schema: Optional[str] = None) -> Table: @@ -650,7 +645,7 @@ class Log(Model): # pylint: disable=too-few-public-methods __tablename__ = "logs" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) action = Column(String(512)) user_id = Column(Integer, ForeignKey("ab_user.id")) dashboard_id = Column(Integer) @@ -667,7 +662,7 @@ class Log(Model): # pylint: disable=too-few-public-methods class FavStar(Model): # pylint: disable=too-few-public-methods __tablename__ = "favstar" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("ab_user.id")) class_name = Column(String(50)) obj_id = Column(Integer) diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index 8779bb7a5f6b..c86f8ff83faa 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -43,6 +43,7 @@ from superset.models.slice import Slice as Slice from superset.models.tags import DashboardUpdater from superset.models.user_attributes import UserAttribute +from superset.tasks.thumbnails import cache_dashboard_thumbnail from superset.utils import core as utils from superset.utils.dashboard_filter_scopes_converter import ( convert_filter_scopes, @@ -119,7 +120,7 @@ class Dashboard( # pylint: disable=too-many-instance-attributes """The dashboard object!""" __tablename__ = "dashboards" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) dashboard_title = Column(String(500)) position_json = Column(utils.MediumText()) description = Column(Text) @@ -184,6 +185,22 @@ def dashboard_link(self) -> Markup: title = escape(self.dashboard_title or "<empty>") return Markup(f'<a href="{self.url}">{title}</a>') + @property + def digest(self) -> str: + """ + Returns a MD5 HEX digest that makes this dashboard unique + """ + unique_string = f"{self.position_json}.{self.css}.{self.json_metadata}" + return utils.md5_hex(unique_string) + + @property + def thumbnail_url(self) -> str: + """ + Returns a thumbnail URL with a HEX digest. We want to avoid browser cache + if the dashboard has changed + """ + return f"/api/v1/dashboard/{self.id}/thumbnail/{self.digest}/" + @property def changed_by_name(self): if not self.changed_by: @@ -452,8 +469,20 @@ def export_dashboards( # pylint: disable=too-many-locals ) +def event_after_dashboard_changed( # pylint: disable=unused-argument + mapper, connection, target +): + cache_dashboard_thumbnail.delay(target.id, force=True) + + # events for updating tags if is_feature_enabled("TAGGING_SYSTEM"): sqla.event.listen(Dashboard, "after_insert", DashboardUpdater.after_insert) sqla.event.listen(Dashboard, "after_update", DashboardUpdater.after_update) sqla.event.listen(Dashboard, "after_delete", DashboardUpdater.after_delete) + + +# events for updating tags +if is_feature_enabled("THUMBNAILS_SQLA_LISTENERS"): + sqla.event.listen(Dashboard, "after_insert", event_after_dashboard_changed) + sqla.event.listen(Dashboard, "after_update", event_after_dashboard_changed) diff --git a/superset/models/datasource_access_request.py b/superset/models/datasource_access_request.py index 803a91115f9a..8940611b5ece 100644 --- a/superset/models/datasource_access_request.py +++ b/superset/models/datasource_access_request.py @@ -37,7 +37,7 @@ class DatasourceAccessRequest(Model, AuditMixinNullable): """ORM model for the access requests for datasources and dbs.""" __tablename__ = "access_request" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) datasource_id = Column(Integer) datasource_type = Column(String(200)) diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 7a1433453571..9a8a5a7bf46b 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -166,14 +166,14 @@ def import_from_dict( try: obj_query = session.query(cls).filter(and_(*filters)) obj = obj_query.one_or_none() - except MultipleResultsFound as e: + except MultipleResultsFound as ex: logger.error( "Error importing %s \n %s \n %s", cls.__name__, str(obj_query), yaml.safe_dump(dict_rep), ) - raise e + raise ex if not obj: is_new_obj = True @@ -274,14 +274,14 @@ def copy(self): return new_obj def alter_params(self, **kwargs): - d = self.params_dict - d.update(kwargs) - self.params = json.dumps(d) + params = self.params_dict + params.update(kwargs) + self.params = json.dumps(params) def remove_params(self, param_to_remove: str) -> None: - d = self.params_dict - d.pop(param_to_remove, None) - self.params = json.dumps(d) + params = self.params_dict + params.pop(param_to_remove, None) + self.params = json.dumps(params) def reset_ownership(self): """ object will belong to the user the current user """ @@ -376,7 +376,7 @@ class QueryResult: # pylint: disable=too-few-public-methods def __init__( # pylint: disable=too-many-arguments self, df, query, duration, status=QueryStatus.SUCCESS, error_message=None ): - self.df: pd.DataFrame = df # pylint: disable=invalid-name + self.df: pd.DataFrame = df self.query: str = query self.duration: int = duration self.status: str = status @@ -395,8 +395,8 @@ def extra(self): except Exception: # pylint: disable=broad-except return {} - def set_extra_json(self, d): - self.extra_json = json.dumps(d) + def set_extra_json(self, extras): + self.extra_json = json.dumps(extras) def set_extra_json_key(self, key, value): extra = self.extra diff --git a/superset/models/schedules.py b/superset/models/schedules.py index 815697ecdaf6..5d10b567658b 100644 --- a/superset/models/schedules.py +++ b/superset/models/schedules.py @@ -50,7 +50,7 @@ class EmailSchedule: __tablename__ = "email_schedules" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) active = Column(Boolean, default=True, index=True) crontab = Column(String(50)) diff --git a/superset/models/slice.py b/superset/models/slice.py index 104ef3421672..24a05f205a0e 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -30,8 +30,13 @@ from superset.legacy import update_time_range from superset.models.helpers import AuditMixinNullable, ImportMixin from superset.models.tags import ChartUpdater +from superset.tasks.thumbnails import cache_chart_thumbnail from superset.utils import core as utils -from superset.viz import BaseViz, viz_types + +if is_feature_enabled("SIP_38_VIZ_REARCHITECTURE"): + from superset.viz_sip38 import BaseViz, viz_types # type: ignore +else: + from superset.viz import BaseViz, viz_types # type: ignore if TYPE_CHECKING: # pylint: disable=unused-import @@ -55,7 +60,7 @@ class Slice( """A slice is essentially a report or a view on data""" __tablename__ = "slices" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) slice_name = Column(String(250)) datasource_id = Column(Integer) datasource_type = Column(String(200)) @@ -135,9 +140,9 @@ def datasource_edit_url(self) -> Optional[str]: @property # type: ignore @utils.memoized def viz(self) -> BaseViz: - d = json.loads(self.params) + form_data = json.loads(self.params) viz_class = viz_types[self.viz_type] - return viz_class(datasource=self.datasource, form_data=d) + return viz_class(datasource=self.datasource, form_data=form_data) @property def description_markeddown(self) -> str: @@ -146,14 +151,14 @@ def description_markeddown(self) -> str: @property def data(self) -> Dict[str, Any]: """Data used to render slice in templates""" - d: Dict[str, Any] = {} + data: Dict[str, Any] = {} self.token = "" try: - d = self.viz.data - self.token = d.get("token") # type: ignore - except Exception as e: # pylint: disable=broad-except - logger.exception(e) - d["error"] = str(e) + data = self.viz.data + self.token = data.get("token") # type: ignore + except Exception as ex: # pylint: disable=broad-except + logger.exception(ex) + data["error"] = str(ex) return { "cache_timeout": self.cache_timeout, "datasource": self.datasource_name, @@ -169,6 +174,21 @@ def data(self) -> Dict[str, Any]: "changed_on": self.changed_on.isoformat(), } + @property + def digest(self) -> str: + """ + Returns a MD5 HEX digest that makes this dashboard unique + """ + return utils.md5_hex(self.params) + + @property + def thumbnail_url(self) -> str: + """ + Returns a thumbnail URL with a HEX digest. We want to avoid browser cache + if the dashboard has changed + """ + return f"/api/v1/chart/{self.id}/thumbnail/{self.digest}/" + @property def json_data(self) -> str: return json.dumps(self.data) @@ -178,9 +198,9 @@ def form_data(self) -> Dict[str, Any]: form_data: Dict[str, Any] = {} try: form_data = json.loads(self.params) - except Exception as e: # pylint: disable=broad-except + except Exception as ex: # pylint: disable=broad-except logger.error("Malformed json in slice's params") - logger.exception(e) + logger.exception(ex) form_data.update( { "slice_id": self.id, @@ -232,23 +252,6 @@ def slice_link(self) -> Markup: def changed_by_url(self) -> str: return f"/superset/profile/{self.created_by.username}" - def get_viz(self, force: bool = False) -> BaseViz: - """Creates :py:class:viz.BaseViz object from the url_params_multidict. - - :return: object of the 'viz_type' type that is taken from the - url_params_multidict or self.params. - :rtype: :py:class:viz.BaseViz - """ - slice_params = json.loads(self.params) - slice_params["slice_id"] = self.id - slice_params["json"] = "false" - slice_params["slice_name"] = self.slice_name - slice_params["viz_type"] = self.viz_type if self.viz_type else "table" - - return viz_types[slice_params.get("viz_type")]( - self.datasource, form_data=slice_params, force=force - ) - @property def icons(self) -> str: return f""" @@ -319,6 +322,12 @@ def set_related_perm(mapper, connection, target): target.schema_perm = ds.schema_perm +def event_after_chart_changed( # pylint: disable=unused-argument + mapper, connection, target +): + cache_chart_thumbnail.delay(target.id, force=True) + + sqla.event.listen(Slice, "before_insert", set_related_perm) sqla.event.listen(Slice, "before_update", set_related_perm) @@ -327,3 +336,8 @@ def set_related_perm(mapper, connection, target): sqla.event.listen(Slice, "after_insert", ChartUpdater.after_insert) sqla.event.listen(Slice, "after_update", ChartUpdater.after_update) sqla.event.listen(Slice, "after_delete", ChartUpdater.after_delete) + +# events for updating tags +if is_feature_enabled("THUMBNAILS_SQLA_LISTENERS"): + sqla.event.listen(Slice, "after_insert", event_after_chart_changed) + sqla.event.listen(Slice, "after_update", event_after_chart_changed) diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index 3dad0da31c40..654bc2fb4c2b 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -48,7 +48,7 @@ class Query(Model, ExtraJSONMixin): table may represent multiple SQL statements executed sequentially""" __tablename__ = "query" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) client_id = Column(String(11), unique=True, nullable=False) database_id = Column(Integer, ForeignKey("dbs.id"), nullable=False) @@ -120,6 +120,7 @@ def to_dict(self): "startDttm": self.start_time, "state": self.status.lower(), "tab": self.tab_name, + "tempSchema": self.tmp_schema_name, "tempTable": self.tmp_table_name, "userId": self.user_id, "user": user_label(self.user), @@ -150,7 +151,7 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin): """ORM model for SQL query""" __tablename__ = "saved_query" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("ab_user.id"), nullable=True) db_id = Column(Integer, ForeignKey("dbs.id"), nullable=True) schema = Column(String(128)) @@ -195,9 +196,7 @@ class TabState(Model, AuditMixinNullable, ExtraJSONMixin): __tablename__ = "tab_state" # basic info - id = Column( # pylint: disable=invalid-name - Integer, primary_key=True, autoincrement=True - ) + id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey("ab_user.id")) label = Column(String(256)) active = Column(Boolean, default=False) @@ -248,9 +247,7 @@ class TableSchema(Model, AuditMixinNullable, ExtraJSONMixin): __tablename__ = "table_schema" - id = Column( # pylint: disable=invalid-name - Integer, primary_key=True, autoincrement=True - ) + id = Column(Integer, primary_key=True, autoincrement=True) tab_state_id = Column(Integer, ForeignKey("tab_state.id", ondelete="CASCADE")) database_id = Column(Integer, ForeignKey("dbs.id"), nullable=False) diff --git a/superset/models/tags.py b/superset/models/tags.py index 779113bec711..0cb00cc4d095 100644 --- a/superset/models/tags.py +++ b/superset/models/tags.py @@ -62,7 +62,7 @@ class Tag(Model, AuditMixinNullable): """A tag attached to an object (query, chart or dashboard).""" __tablename__ = "tag" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) name = Column(String(250), unique=True) type = Column(Enum(TagTypes)) @@ -72,7 +72,7 @@ class TaggedObject(Model, AuditMixinNullable): """An association between an object and a tag.""" __tablename__ = "tagged_object" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) tag_id = Column(Integer, ForeignKey("tag.id")) object_id = Column(Integer) object_type = Column(Enum(ObjectTypes)) diff --git a/superset/models/user_attributes.py b/superset/models/user_attributes.py index 2c69feb971ac..648e5307c603 100644 --- a/superset/models/user_attributes.py +++ b/superset/models/user_attributes.py @@ -34,7 +34,7 @@ class UserAttribute(Model, AuditMixinNullable): """ __tablename__ = "user_attribute" - id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("ab_user.id")) user = relationship( security_manager.user_model, backref="extra_attributes", foreign_keys=[user_id] diff --git a/superset/queries/__init__.py b/superset/queries/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/queries/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/queries/api.py b/superset/queries/api.py new file mode 100644 index 000000000000..092989f5eff0 --- /dev/null +++ b/superset/queries/api.py @@ -0,0 +1,72 @@ +# 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 logging + +from flask_appbuilder.models.sqla.interface import SQLAInterface + +from superset.constants import RouteMethod +from superset.models.sql_lab import Query +from superset.queries.filters import QueryFilter +from superset.views.base_api import BaseSupersetModelRestApi + +logger = logging.getLogger(__name__) + + +class QueryRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(Query) + + resource_name = "query" + allow_browser_login = True + include_route_methods = {RouteMethod.GET, RouteMethod.GET_LIST} + + class_permission_name = "QueryView" + list_columns = [ + "user.username", + "database.database_name", + "status", + "start_time", + "end_time", + ] + show_columns = [ + "client_id", + "tmp_table_name", + "tmp_schema_name", + "status", + "tab_name", + "sql_editor_id", + "schema", + "sql", + "select_sql", + "executed_sql", + "limit", + "select_as_cta", + "select_as_cta_used", + "progress", + "rows", + "error_message", + "results_key", + "start_time", + "start_running_time", + "end_time", + "end_result_backend_time", + "tracking_url", + "changed_on", + ] + base_filters = [["id", QueryFilter, lambda: []]] + base_order = ("changed_on", "desc") + + openapi_spec_tag = "Queries" diff --git a/superset/queries/dao.py b/superset/queries/dao.py new file mode 100644 index 000000000000..a3317d2e8277 --- /dev/null +++ b/superset/queries/dao.py @@ -0,0 +1,28 @@ +# 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 logging + +from superset.dao.base import BaseDAO +from superset.models.sql_lab import Query +from superset.queries.filters import QueryFilter + +logger = logging.getLogger(__name__) + + +class QueryDAO(BaseDAO): + model_cls = Query + base_filter = QueryFilter diff --git a/superset/queries/filters.py b/superset/queries/filters.py new file mode 100644 index 000000000000..323c3c6cf2d8 --- /dev/null +++ b/superset/queries/filters.py @@ -0,0 +1,37 @@ +# 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. +from typing import Callable + +from flask import g +from flask_sqlalchemy import BaseQuery + +from superset import security_manager +from superset.models.sql_lab import Query +from superset.views.base import BaseFilter + + +class QueryFilter(BaseFilter): # pylint: disable=too-few-public-methods + def apply(self, query: BaseQuery, value: Callable) -> BaseQuery: + """ + Filter queries to only those owned by current user. If + can_access_all_queries permission is set a user can list all queries + + :returns: query + """ + if not security_manager.can_access_all_queries(): + query = query.filter(Query.user_id == g.user.get_user_id()) + return query diff --git a/superset/result_set.py b/superset/result_set.py index 1f42a28d5777..4166d1a5b96e 100644 --- a/superset/result_set.py +++ b/superset/result_set.py @@ -138,8 +138,8 @@ def __init__( pa_data[i] = pa.Array.from_pandas( series, type=pa.timestamp("ns", tz=tz) ) - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) self.table = pa.Table.from_arrays(pa_data, names=column_names) self._type_dict: Dict[str, Any] = {} @@ -150,8 +150,8 @@ def __init__( for i, col in enumerate(column_names) if deduped_cursor_desc } - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) @staticmethod def convert_pa_dtype(pa_dtype: pa.DataType) -> Optional[str]: diff --git a/superset/security/manager.py b/superset/security/manager.py index fe39b1924d60..01c80d6cb6a9 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -772,6 +772,7 @@ def _is_sql_lab_pvm(self, pvm: PermissionModelView) -> bool: "can_csv", "can_search_queries", "can_sqllab_viz", + "can_sqllab_table_viz", "can_sqllab", } or ( @@ -918,7 +919,7 @@ def get_rls_filters(self, table: "BaseDatasource"): .subquery() ) filter_roles = ( - db.session.query(RLSFilterRoles.c.id) + db.session.query(RLSFilterRoles.c.rls_filter_id) .filter(RLSFilterRoles.c.role_id.in_(user_roles)) .subquery() ) diff --git a/superset/sql_lab.py b/superset/sql_lab.py index 01dd2e5bb130..65d61f4cd8da 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -135,9 +135,9 @@ def session_scope(nullpool): try: yield session session.commit() - except Exception as e: + except Exception as ex: session.rollback() - logger.exception(e) + logger.exception(ex) raise finally: session.close() @@ -175,12 +175,12 @@ def get_sql_results( # pylint: disable=too-many-arguments expand_data=expand_data, log_params=log_params, ) - except Exception as e: # pylint: disable=broad-except + except Exception as ex: # pylint: disable=broad-except logger.error("Query %d", query_id) - logger.debug("Query %d: %s", query_id, e) + logger.debug("Query %d: %s", query_id, ex) stats_logger.incr("error_sqllab_unhandled") query = get_query(query_id, session) - return handle_query_error(str(e), query, session) + return handle_query_error(str(ex), query, session) # pylint: disable=too-many-arguments @@ -253,17 +253,17 @@ def execute_sql_statement(sql_statement, query, user_name, session, cursor, log_ ) data = db_engine_spec.fetch_data(cursor, query.limit) - except SoftTimeLimitExceeded as e: + except SoftTimeLimitExceeded as ex: logger.error("Query %d: Time limit exceeded", query.id) - logger.debug("Query %d: %s", query.id, e) + logger.debug("Query %d: %s", query.id, ex) raise SqlLabTimeoutException( "SQL Lab timeout. This environment's policy is to kill queries " "after {} seconds.".format(SQLLAB_TIMEOUT) ) - except Exception as e: - logger.error("Query %d: %s", query.id, type(e)) - logger.debug("Query %d: %s", query.id, e) - raise SqlLabException(db_engine_spec.extract_error_message(e)) + except Exception as ex: + logger.error("Query %d: %s", query.id, type(ex)) + logger.debug("Query %d: %s", query.id, ex) + raise SqlLabException(db_engine_spec.extract_error_message(ex)) logger.debug("Query %d: Fetching cursor description", query.id) cursor_description = cursor.description @@ -378,8 +378,8 @@ def execute_sql_statements( result_set = execute_sql_statement( statement, query, user_name, session, cursor, log_params ) - except Exception as e: # pylint: disable=broad-except - msg = str(e) + except Exception as ex: # pylint: disable=broad-except + msg = str(ex) if statement_count > 1: msg = f"[Statement {i+1} out of {statement_count}] " + msg payload = handle_query_error(msg, query, session, payload) diff --git a/superset/sql_validators/presto_db.py b/superset/sql_validators/presto_db.py index caf8dc23764d..fc5efda27c17 100644 --- a/superset/sql_validators/presto_db.py +++ b/superset/sql_validators/presto_db.py @@ -136,9 +136,9 @@ def validate_statement( start_column=start_column, end_column=end_column, ) - except Exception as e: - logger.exception(f"Unexpected error running validation query: {e}") - raise e + except Exception as ex: + logger.exception(f"Unexpected error running validation query: {ex}") + raise ex @classmethod def validate( diff --git a/superset/stats_logger.py b/superset/stats_logger.py index 758208a6040f..37fe3d39d6f0 100644 --- a/superset/stats_logger.py +++ b/superset/stats_logger.py @@ -24,26 +24,26 @@ class BaseStatsLogger: """Base class for logging realtime events""" - def __init__(self, prefix="superset"): + def __init__(self, prefix: str = "superset") -> None: self.prefix = prefix - def key(self, key): + def key(self, key: str) -> str: if self.prefix: return self.prefix + key return key - def incr(self, key): + def incr(self, key: str) -> None: """Increment a counter""" raise NotImplementedError() - def decr(self, key): + def decr(self, key: str) -> None: """Decrement a counter""" raise NotImplementedError() - def timing(self, key, value): + def timing(self, key, value: float) -> None: raise NotImplementedError() - def gauge(self, key): + def gauge(self, key: str) -> None: """Setup a gauge""" raise NotImplementedError() diff --git a/superset/tasks/thumbnails.py b/superset/tasks/thumbnails.py new file mode 100644 index 000000000000..72c7bdaf673f --- /dev/null +++ b/superset/tasks/thumbnails.py @@ -0,0 +1,53 @@ +# 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. +# pylint: disable=C,R,W + +"""Utility functions used across Superset""" + +import logging + +from flask import current_app + +from superset import app, security_manager, thumbnail_cache +from superset.extensions import celery_app +from superset.utils.screenshots import ChartScreenshot, DashboardScreenshot + +logger = logging.getLogger(__name__) + + +@celery_app.task(name="cache_chart_thumbnail", soft_time_limit=300) +def cache_chart_thumbnail(chart_id: int, force: bool = False): + with app.app_context(): + if not thumbnail_cache: + logger.warning("No cache set, refusing to compute") + return None + logging.info(f"Caching chart {chart_id}") + screenshot = ChartScreenshot(model_id=chart_id) + user = security_manager.find_user(current_app.config["THUMBNAIL_SELENIUM_USER"]) + screenshot.compute_and_cache(user=user, cache=thumbnail_cache, force=force) + + +@celery_app.task(name="cache_dashboard_thumbnail", soft_time_limit=300) +def cache_dashboard_thumbnail(dashboard_id: int, force: bool = False): + with app.app_context(): + if not thumbnail_cache: + logging.warning("No cache set, refusing to compute") + return None + logger.info(f"Caching dashboard {dashboard_id}") + screenshot = DashboardScreenshot(model_id=dashboard_id) + user = security_manager.find_user(current_app.config["THUMBNAIL_SELENIUM_USER"]) + screenshot.compute_and_cache(user=user, cache=thumbnail_cache, force=force) diff --git a/superset/templates/superset/models/database/add.html b/superset/templates/superset/models/database/add.html index 98d511c9cd2e..6188c07f8127 100644 --- a/superset/templates/superset/models/database/add.html +++ b/superset/templates/superset/models/database/add.html @@ -24,4 +24,5 @@ {{ macros.testconn() }} {{ macros.expand_extra_textarea() }} {{ macros.expand_encrypted_extra_textarea() }} + {{ macros.expand_server_cert_textarea() }} {% endblock %} diff --git a/superset/templates/superset/models/database/edit.html b/superset/templates/superset/models/database/edit.html index 42a340a817c1..c7b80daee0be 100644 --- a/superset/templates/superset/models/database/edit.html +++ b/superset/templates/superset/models/database/edit.html @@ -24,4 +24,5 @@ {{ macros.testconn() }} {{ macros.expand_extra_textarea() }} {{ macros.expand_encrypted_extra_textarea() }} + {{ macros.expand_server_cert_textarea() }} {% endblock %} diff --git a/superset/templates/superset/models/database/macros.html b/superset/templates/superset/models/database/macros.html index 200bac93e6e2..f6a054e4558b 100644 --- a/superset/templates/superset/models/database/macros.html +++ b/superset/templates/superset/models/database/macros.html @@ -43,6 +43,7 @@ impersonate_user: $('#impersonate_user').is(':checked'), extras: extra ? JSON.parse(extra) : {}, encrypted_extra: encryptedExtra ? JSON.parse(encryptedExtra) : {}, + server_cert: $("#server_cert").val(), }) } catch(parse_error){ alert("Malformed JSON in the extras field: " + parse_error); @@ -81,3 +82,9 @@ $('#encrypted_extra').attr('rows', '5'); </script> {% endmacro %} + +{% macro expand_server_cert_textarea() %} + <script> + $('#server_cert').attr('rows', '5'); + </script> +{% endmacro %} diff --git a/superset/translations/utils.py b/superset/translations/utils.py index 2e50cc7e981a..bfb12bbe34c1 100644 --- a/superset/translations/utils.py +++ b/superset/translations/utils.py @@ -36,7 +36,7 @@ def get_language_pack(locale): if not pack: filename = DIR + "/{}/LC_MESSAGES/messages.json".format(locale) try: - with open(filename) as f: + with open(filename, encoding="utf8") as f: pack = json.load(f) ALL_LANGUAGE_PACKS[locale] = pack except Exception: # pylint: disable=broad-except diff --git a/superset/typing.py b/superset/typing.py index c84e6d3a4d00..b6686ecfec5b 100644 --- a/superset/typing.py +++ b/superset/typing.py @@ -25,4 +25,6 @@ ] DbapiDescription = Union[List[DbapiDescriptionRow], Tuple[DbapiDescriptionRow, ...]] DbapiResult = List[Union[List[Any], Tuple[Any, ...]]] +FilterValue = Union[float, int, str] +FilterValues = Union[FilterValue, List[FilterValue], Tuple[FilterValue]] VizData = Optional[Union[List[Any], Dict[Any, Any]]] diff --git a/superset/utils/cache_manager.py b/superset/utils/cache_manager.py index a098a16d3c50..4a625ea6d00b 100644 --- a/superset/utils/cache_manager.py +++ b/superset/utils/cache_manager.py @@ -26,12 +26,16 @@ def __init__(self) -> None: self._tables_cache = None self._cache = None + self._thumbnail_cache = None def init_app(self, app: Flask) -> None: self._cache = self._setup_cache(app, app.config["CACHE_CONFIG"]) self._tables_cache = self._setup_cache( app, app.config["TABLE_NAMES_CACHE_CONFIG"] ) + self._thumbnail_cache = self._setup_cache( + app, app.config["THUMBNAIL_CACHE_CONFIG"] + ) @staticmethod def _setup_cache(app: Flask, cache_config: CacheConfig) -> Cache: @@ -50,3 +54,7 @@ def tables_cache(self) -> Cache: @property def cache(self) -> Cache: return self._cache + + @property + def thumbnail_cache(self) -> Cache: + return self._thumbnail_cache diff --git a/superset/utils/core.py b/superset/utils/core.py index 23d6d4ef9256..5749930aee34 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -19,12 +19,14 @@ import decimal import errno import functools +import hashlib import json import logging import os import re import signal import smtplib +import tempfile import traceback import uuid import zlib @@ -36,7 +38,20 @@ from email.utils import formatdate from enum import Enum from time import struct_time -from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple, Union +from timeit import default_timer +from typing import ( + Any, + Callable, + Dict, + Iterator, + List, + NamedTuple, + Optional, + Set, + Tuple, + TYPE_CHECKING, + Union, +) from urllib.parse import unquote_plus import bleach @@ -45,9 +60,12 @@ import pandas as pd import parsedatetime import sqlalchemy as sa +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.backends.openssl.x509 import _Certificate from dateutil.parser import parse from dateutil.relativedelta import relativedelta -from flask import current_app, flash, Flask, g, Markup, render_template +from flask import current_app, flash, g, Markup, render_template from flask_appbuilder import SQLA from flask_appbuilder.security.sqla.models import User from flask_babel import gettext as __, lazy_gettext as _ @@ -56,7 +74,11 @@ from sqlalchemy.sql.type_api import Variant from sqlalchemy.types import TEXT, TypeDecorator -from superset.exceptions import SupersetException, SupersetTimeoutException +from superset.exceptions import ( + CertificateException, + SupersetException, + SupersetTimeoutException, +) from superset.utils.dates import datetime_to_epoch, EPOCH try: @@ -64,6 +86,9 @@ except ImportError: pass +if TYPE_CHECKING: + from superset.models.core import Database + logging.getLogger("MARKDOWN").setLevel(logging.INFO) logger = logging.getLogger(__name__) @@ -173,28 +198,30 @@ def parse_js_uri_path_item( return unquote_plus(item) if unquote and item else item -def string_to_num(s: str): - """Converts a string to an int/float - - Returns ``None`` if it can't be converted +def cast_to_num(value: Union[float, int, str]) -> Optional[Union[float, int]]: + """Casts a value to an int/float - >>> string_to_num('5') + >>> cast_to_num('5') 5 - >>> string_to_num('5.2') + >>> cast_to_num('5.2') 5.2 - >>> string_to_num(10) + >>> cast_to_num(10) 10 - >>> string_to_num(10.1) + >>> cast_to_num(10.1) 10.1 - >>> string_to_num('this is not a string') is None + >>> cast_to_num('this is not a string') is None True + + :param value: value to be converted to numeric representation + :returns: value cast to `int` if value is all digits, `float` if `value` is + decimal value and `None`` if it can't be converted """ - if isinstance(s, (int, float)): - return s - if s.isdigit(): - return int(s) + if isinstance(value, (int, float)): + return value + if value.isdigit(): + return int(value) try: - return float(s) + return float(value) except ValueError: return None @@ -241,8 +268,8 @@ def parse_human_datetime(s): if parsed_flags & 2 == 0: parsed_dttm = parsed_dttm.replace(hour=0, minute=0, second=0) dttm = dttm_from_timetuple(parsed_dttm.utctimetuple()) - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) raise ValueError("Couldn't parse date string [{}]".format(s)) return dttm @@ -251,6 +278,10 @@ def dttm_from_timetuple(d: struct_time) -> datetime: return datetime(d.tm_year, d.tm_mon, d.tm_mday, d.tm_hour, d.tm_min, d.tm_sec) +def md5_hex(data: str) -> str: + return hashlib.md5(data.encode()).hexdigest() + + class DashboardEncoder(json.JSONEncoder): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -539,12 +570,12 @@ def get_datasource_full_name(database_name, datasource_name, schema=None): return "[{}].[{}].[{}]".format(database_name, schema, datasource_name) -def validate_json(obj): +def validate_json(obj: Union[bytes, bytearray, str]) -> None: if obj: try: json.loads(obj) - except Exception as e: - logger.error(f"JSON is not valid {e}") + except Exception as ex: + logger.error(f"JSON is not valid {ex}") raise SupersetException("JSON is not valid") @@ -575,16 +606,16 @@ def __enter__(self): try: signal.signal(signal.SIGALRM, self.handle_timeout) signal.alarm(self.seconds) - except ValueError as e: + except ValueError as ex: logger.warning("timeout can't be used in the current context") - logger.exception(e) + logger.exception(ex) def __exit__(self, type, value, traceback): try: signal.alarm(0) - except ValueError as e: + except ValueError as ex: logger.warning("timeout can't be used in the current context") - logger.exception(e) + logger.exception(ex) def pessimistic_connection_handling(some_engine): @@ -757,14 +788,7 @@ def send_MIME_email(e_from, e_to, mime_msg, config, dryrun=False): def get_email_address_list(address_string: str) -> List[str]: address_string_list: List[str] = [] if isinstance(address_string, str): - if "," in address_string: - address_string_list = address_string.split(",") - elif "\n" in address_string: - address_string_list = address_string.split("\n") - elif ";" in address_string: - address_string_list = address_string.split(";") - else: - address_string_list = [address_string] + address_string_list = re.split(",|\s|;", address_string) return [x.strip() for x in address_string_list if x.strip()] @@ -805,6 +829,7 @@ def to_adhoc(filt, expressionType="SIMPLE", clause="where"): "clause": clause.upper(), "expressionType": expressionType, "filterOptionName": str(uuid.uuid4()), + "isExtra": True if filt.get("isExtra") is True else False, } if expressionType == "SIMPLE": @@ -860,6 +885,7 @@ def get_filter_key(f): existing_filters[get_filter_key(existing)] = existing["comparator"] for filtr in form_data["extra_filters"]: + filtr["isExtra"] = True # Pull out time filters/options and merge into form data if date_options.get(filtr["col"]): if filtr.get("val"): @@ -934,7 +960,7 @@ def get_or_create_db(database_name, sqlalchemy_uri, *args, **kwargs): return database -def get_example_database(): +def get_example_database() -> "Database": from superset import conf db_uri = conf.get("SQLALCHEMY_EXAMPLES_URI") or conf.get("SQLALCHEMY_DATABASE_URI") @@ -1048,13 +1074,13 @@ def get_since_until( rel, num, grain = time_range.split() if rel == "Last": since = relative_start - relativedelta( # type: ignore - **{grain: int(num)} + **{grain: int(num)} # type: ignore ) until = relative_end else: # rel == 'Next' since = relative_start until = relative_end + relativedelta( # type: ignore - **{grain: int(num)} + **{grain: int(num)} # type: ignore ) else: since = since or "" @@ -1161,6 +1187,61 @@ def get_username() -> Optional[str]: return None +def parse_ssl_cert(certificate: str) -> _Certificate: + """ + Parses the contents of a certificate and returns a valid certificate object + if valid. + + :param certificate: Contents of certificate file + :return: Valid certificate instance + :raises CertificateException: If certificate is not valid/unparseable + """ + try: + return x509.load_pem_x509_certificate( + certificate.encode("utf-8"), default_backend() + ) + except ValueError: + raise CertificateException("Invalid certificate") + + +def create_ssl_cert_file(certificate: str) -> str: + """ + This creates a certificate file that can be used to validate HTTPS + sessions. A certificate is only written to disk once; on subsequent calls, + only the path of the existing certificate is returned. + + :param certificate: The contents of the certificate + :return: The path to the certificate file + :raises CertificateException: If certificate is not valid/unparseable + """ + filename = f"{hashlib.md5(certificate.encode('utf-8')).hexdigest()}.crt" + cert_dir = current_app.config["SSL_CERT_PATH"] + path = cert_dir if cert_dir else tempfile.gettempdir() + path = os.path.join(path, filename) + if not os.path.exists(path): + # Validate certificate prior to persisting to temporary directory + parse_ssl_cert(certificate) + cert_file = open(path, "w") + cert_file.write(certificate) + cert_file.close() + return path + + +def time_function(func: Callable, *args, **kwargs) -> Tuple[float, Any]: + """ + Measures the amount of time a function takes to execute in ms + + :param func: The function execution time to measure + :param args: args to be passed to the function + :param kwargs: kwargs to be passed to the function + :return: A tuple with the duration and response from the function + """ + start = default_timer() + response = func(*args, **kwargs) + stop = default_timer() + return stop - start, response + + def MediumText() -> Variant: return Text().with_variant(MEDIUMTEXT(), "mysql") @@ -1174,9 +1255,10 @@ class DatasourceName(NamedTuple): schema: str -def get_stacktrace(): +def get_stacktrace() -> Optional[str]: if current_app.config["SHOW_STACKTRACE"]: return traceback.format_exc() + return None def split( @@ -1266,3 +1348,22 @@ class DbColumnType(Enum): NUMERIC = 0 STRING = 1 TEMPORAL = 2 + + +class FilterOperationType(str, Enum): + """ + Filter operation type + """ + + EQUALS = "==" + NOT_EQUALS = "!=" + GREATER_THAN = ">" + LESS_THAN = "<" + GREATER_THAN_OR_EQUALS = ">=" + LESS_THAN_OR_EQUALS = "<=" + LIKE = "LIKE" + IS_NULL = "IS NULL" + IS_NOT_NULL = "IS NOT NULL" + IN = "IN" + NOT_IN = "NOT IN" + REGEX = "REGEX" diff --git a/superset/utils/decorators.py b/superset/utils/decorators.py index c7d23ec5158c..52ba61f82ff3 100644 --- a/superset/utils/decorators.py +++ b/superset/utils/decorators.py @@ -37,8 +37,8 @@ def stats_timing(stats_key, stats_logger): start_ts = now_as_float() try: yield start_ts - except Exception as e: - raise e + except Exception as ex: + raise ex finally: stats_logger.timing(stats_key, now_as_float() - start_ts) diff --git a/superset/utils/log.py b/superset/utils/log.py index 98e344ff5fa1..5d8c52e873cf 100644 --- a/superset/utils/log.py +++ b/superset/utils/log.py @@ -37,19 +37,19 @@ def wrapper(*args, **kwargs): user_id = None if g.user: user_id = g.user.get_id() - d = request.form.to_dict() or {} + form_data = request.form.to_dict() or {} # request parameters can overwrite post body request_params = request.args.to_dict() - d.update(request_params) - d.update(kwargs) + form_data.update(request_params) + form_data.update(kwargs) - slice_id = d.get("slice_id") - dashboard_id = d.get("dashboard_id") + slice_id = form_data.get("slice_id") + dashboard_id = form_data.get("dashboard_id") try: slice_id = int( - slice_id or json.loads(d.get("form_data")).get("slice_id") + slice_id or json.loads(form_data.get("form_data")).get("slice_id") ) except (ValueError, TypeError): slice_id = 0 @@ -61,10 +61,10 @@ def wrapper(*args, **kwargs): # bulk insert try: - explode_by = d.get("explode") - records = json.loads(d.get(explode_by)) + explode_by = form_data.get("explode") + records = json.loads(form_data.get(explode_by)) except Exception: # pylint: disable=broad-except - records = [d] + records = [form_data] referrer = request.referrer[:1000] if request.referrer else None diff --git a/superset/utils/pandas_postprocessing.py b/superset/utils/pandas_postprocessing.py new file mode 100644 index 000000000000..f2a688c252ee --- /dev/null +++ b/superset/utils/pandas_postprocessing.py @@ -0,0 +1,390 @@ +# 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. +from functools import partial +from typing import Any, Callable, Dict, List, Optional, Union + +import numpy as np +from flask_babel import gettext as _ +from pandas import DataFrame, NamedAgg + +from superset.exceptions import QueryObjectValidationError + +WHITELIST_NUMPY_FUNCTIONS = ( + "average", + "argmin", + "argmax", + "cumsum", + "cumprod", + "max", + "mean", + "median", + "nansum", + "nanmin", + "nanmax", + "nanmean", + "nanmedian", + "min", + "percentile", + "prod", + "product", + "std", + "sum", + "var", +) + +WHITELIST_ROLLING_FUNCTIONS = ( + "count", + "corr", + "cov", + "kurt", + "max", + "mean", + "median", + "min", + "std", + "skew", + "sum", + "var", + "quantile", +) + +WHITELIST_CUMULATIVE_FUNCTIONS = ( + "cummax", + "cummin", + "cumprod", + "cumsum", +) + + +def validate_column_args(*argnames: str) -> Callable: + def wrapper(func): + def wrapped(df, **options): + columns = df.columns.tolist() + for name in argnames: + if name in options and not all( + elem in columns for elem in options[name] + ): + raise QueryObjectValidationError( + _("Referenced columns not available in DataFrame.") + ) + return func(df, **options) + + return wrapped + + return wrapper + + +def _get_aggregate_funcs( + df: DataFrame, aggregates: Dict[str, Dict[str, Any]], +) -> Dict[str, NamedAgg]: + """ + Converts a set of aggregate config objects into functions that pandas can use as + aggregators. Currently only numpy aggregators are supported. + + :param df: DataFrame on which to perform aggregate operation. + :param aggregates: Mapping from column name to aggregate config. + :return: Mapping from metric name to function that takes a single input argument. + """ + agg_funcs: Dict[str, NamedAgg] = {} + for name, agg_obj in aggregates.items(): + column = agg_obj.get("column", name) + if column not in df: + raise QueryObjectValidationError( + _( + "Column referenced by aggregate is undefined: %(column)s", + column=column, + ) + ) + if "operator" not in agg_obj: + raise QueryObjectValidationError( + _("Operator undefined for aggregator: %(name)s", name=name,) + ) + operator = agg_obj["operator"] + if operator not in WHITELIST_NUMPY_FUNCTIONS or not hasattr(np, operator): + raise QueryObjectValidationError( + _("Invalid numpy function: %(operator)s", operator=operator,) + ) + func = getattr(np, operator) + options = agg_obj.get("options", {}) + agg_funcs[name] = NamedAgg(column=column, aggfunc=partial(func, **options)) + + return agg_funcs + + +def _append_columns( + base_df: DataFrame, append_df: DataFrame, columns: Dict[str, str] +) -> DataFrame: + """ + Function for adding columns from one DataFrame to another DataFrame. Calls the + assign method, which overwrites the original column in `base_df` if the column + already exists, and appends the column if the name is not defined. + + :param base_df: DataFrame which to use as the base + :param append_df: DataFrame from which to select data. + :param columns: columns on which to append, mapping source column to + target column. For instance, `{'y': 'y'}` will replace the values in + column `y` in `base_df` with the values in `y` in `append_df`, + while `{'y': 'y2'}` will add a column `y2` to `base_df` based + on values in column `y` in `append_df`, leaving the original column `y` + in `base_df` unchanged. + :return: new DataFrame with combined data from `base_df` and `append_df` + """ + return base_df.assign( + **{ + target: append_df[append_df.columns[idx]] + for idx, target in enumerate(columns.values()) + } + ) + + +@validate_column_args("index", "columns") +def pivot( # pylint: disable=too-many-arguments + df: DataFrame, + index: List[str], + columns: List[str], + aggregates: Dict[str, Dict[str, Any]], + metric_fill_value: Optional[Any] = None, + column_fill_value: Optional[str] = None, + drop_missing_columns: Optional[bool] = True, + combine_value_with_metric=False, + marginal_distributions: Optional[bool] = None, + marginal_distribution_name: Optional[str] = None, +) -> DataFrame: + """ + Perform a pivot operation on a DataFrame. + + :param df: Object on which pivot operation will be performed + :param index: Columns to group by on the table index (=rows) + :param columns: Columns to group by on the table columns + :param metric_fill_value: Value to replace missing values with + :param column_fill_value: Value to replace missing pivot columns with + :param drop_missing_columns: Do not include columns whose entries are all missing + :param combine_value_with_metric: Display metrics side by side within each column, + as opposed to each column being displayed side by side for each metric. + :param aggregates: A mapping from aggregate column name to the the aggregate + config. + :param marginal_distributions: Add totals for row/column. Default to False + :param marginal_distribution_name: Name of row/column with marginal distribution. + Default to 'All'. + :return: A pivot table + :raises ChartDataValidationError: If the request in incorrect + """ + if not index: + raise QueryObjectValidationError( + _("Pivot operation requires at least one index") + ) + if not columns: + raise QueryObjectValidationError( + _("Pivot operation requires at least one column") + ) + if not aggregates: + raise QueryObjectValidationError( + _("Pivot operation must include at least one aggregate") + ) + + if column_fill_value: + df[columns] = df[columns].fillna(value=column_fill_value) + + aggregate_funcs = _get_aggregate_funcs(df, aggregates) + + # TODO (villebro): Pandas 1.0.3 doesn't yet support NamedAgg in pivot_table. + # Remove once/if support is added. + aggfunc = {na.column: na.aggfunc for na in aggregate_funcs.values()} + + df = df.pivot_table( + values=aggfunc.keys(), + index=index, + columns=columns, + aggfunc=aggfunc, + fill_value=metric_fill_value, + dropna=drop_missing_columns, + margins=marginal_distributions, + margins_name=marginal_distribution_name, + ) + + if combine_value_with_metric: + df = df.stack(0).unstack() + + return df + + +@validate_column_args("groupby") +def aggregate( + df: DataFrame, groupby: List[str], aggregates: Dict[str, Dict[str, Any]] +) -> DataFrame: + """ + Apply aggregations to a DataFrame. + + :param df: Object to aggregate. + :param groupby: columns to aggregate + :param aggregates: A mapping from metric column to the function used to + aggregate values. + :raises ChartDataValidationError: If the request in incorrect + """ + aggregates = aggregates or {} + aggregate_funcs = _get_aggregate_funcs(df, aggregates) + return df.groupby(by=groupby).agg(**aggregate_funcs).reset_index() + + +@validate_column_args("columns") +def sort(df: DataFrame, columns: Dict[str, bool]) -> DataFrame: + """ + Sort a DataFrame. + + :param df: DataFrame to sort. + :param columns: columns by by which to sort. The key specifies the column name, + value specifies if sorting in ascending order. + :return: Sorted DataFrame + :raises ChartDataValidationError: If the request in incorrect + """ + return df.sort_values(by=list(columns.keys()), ascending=list(columns.values())) + + +@validate_column_args("columns") +def rolling( # pylint: disable=too-many-arguments + df: DataFrame, + columns: Dict[str, str], + rolling_type: str, + window: int, + rolling_type_options: Optional[Dict[str, Any]] = None, + center: bool = False, + win_type: Optional[str] = None, + min_periods: Optional[int] = None, +) -> DataFrame: + """ + Apply a rolling window on the dataset. See the Pandas docs for further details: + https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.rolling.html + + :param df: DataFrame on which the rolling period will be based. + :param columns: columns on which to perform rolling, mapping source column to + target column. For instance, `{'y': 'y'}` will replace the column `y` with + the rolling value in `y`, while `{'y': 'y2'}` will add a column `y2` based + on rolling values calculated from `y`, leaving the original column `y` + unchanged. + :param rolling_type: Type of rolling window. Any numpy function will work. + :param window: Size of the window. + :param rolling_type_options: Optional options to pass to rolling method. Needed + for e.g. quantile operation. + :param center: Should the label be at the center of the window. + :param win_type: Type of window function. + :param min_periods: The minimum amount of periods required for a row to be included + in the result set. + :return: DataFrame with the rolling columns + :raises ChartDataValidationError: If the request in incorrect + """ + rolling_type_options = rolling_type_options or {} + df_rolling = df[columns.keys()] + kwargs: Dict[str, Union[str, int]] = {} + if not window: + raise QueryObjectValidationError(_("Undefined window for rolling operation")) + + kwargs["window"] = window + if min_periods is not None: + kwargs["min_periods"] = min_periods + if center is not None: + kwargs["center"] = center + if win_type is not None: + kwargs["win_type"] = win_type + + df_rolling = df_rolling.rolling(**kwargs) + if rolling_type not in WHITELIST_ROLLING_FUNCTIONS or not hasattr( + df_rolling, rolling_type + ): + raise QueryObjectValidationError( + _("Invalid rolling_type: %(type)s", type=rolling_type) + ) + try: + df_rolling = getattr(df_rolling, rolling_type)(**rolling_type_options) + except TypeError: + raise QueryObjectValidationError( + _( + "Invalid options for %(rolling_type)s: %(options)s", + rolling_type=rolling_type, + options=rolling_type_options, + ) + ) + df = _append_columns(df, df_rolling, columns) + if min_periods: + df = df[min_periods:] + return df + + +@validate_column_args("columns", "rename") +def select( + df: DataFrame, columns: List[str], rename: Optional[Dict[str, str]] = None +) -> DataFrame: + """ + Only select a subset of columns in the original dataset. Can be useful for + removing unnecessary intermediate results, renaming and reordering columns. + + :param df: DataFrame on which the rolling period will be based. + :param columns: Columns which to select from the DataFrame, in the desired order. + If columns are renamed, the old column name should be referenced + here. + :param rename: columns which to rename, mapping source column to target column. + For instance, `{'y': 'y2'}` will rename the column `y` to + `y2`. + :return: Subset of columns in original DataFrame + :raises ChartDataValidationError: If the request in incorrect + """ + df_select = df[columns] + if rename is not None: + df_select = df_select.rename(columns=rename) + return df_select + + +@validate_column_args("columns") +def diff(df: DataFrame, columns: Dict[str, str], periods: int = 1,) -> DataFrame: + """ + + :param df: DataFrame on which the diff will be based. + :param columns: columns on which to perform diff, mapping source column to + target column. For instance, `{'y': 'y'}` will replace the column `y` with + the diff value in `y`, while `{'y': 'y2'}` will add a column `y2` based + on diff values calculated from `y`, leaving the original column `y` + unchanged. + :param periods: periods to shift for calculating difference. + :return: DataFrame with diffed columns + :raises ChartDataValidationError: If the request in incorrect + """ + df_diff = df[columns.keys()] + df_diff = df_diff.diff(periods=periods) + return _append_columns(df, df_diff, columns) + + +@validate_column_args("columns") +def cum(df: DataFrame, columns: Dict[str, str], operator: str) -> DataFrame: + """ + + :param df: DataFrame on which the cumulative operation will be based. + :param columns: columns on which to perform a cumulative operation, mapping source + column to target column. For instance, `{'y': 'y'}` will replace the column + `y` with the cumulative value in `y`, while `{'y': 'y2'}` will add a column + `y2` based on cumulative values calculated from `y`, leaving the original + column `y` unchanged. + :param operator: cumulative operator, e.g. `sum`, `prod`, `min`, `max` + :return: + """ + df_cum = df[columns.keys()] + operation = "cum" + operator + if operation not in WHITELIST_CUMULATIVE_FUNCTIONS or not hasattr( + df_cum, operation + ): + raise QueryObjectValidationError( + _("Invalid cumulative operator: %(operator)s", operator=operator) + ) + return _append_columns(df, getattr(df_cum, operation)(), columns) diff --git a/superset/utils/screenshots.py b/superset/utils/screenshots.py new file mode 100644 index 000000000000..18283e7f0d22 --- /dev/null +++ b/superset/utils/screenshots.py @@ -0,0 +1,329 @@ +# 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 logging +import time +import urllib.parse +from io import BytesIO +from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING + +from flask import current_app, request, Response, session, url_for +from flask_login import login_user +from retry.api import retry_call +from selenium.common.exceptions import TimeoutException, WebDriverException +from selenium.webdriver import chrome, firefox +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait +from werkzeug.http import parse_cookie + +logger = logging.getLogger(__name__) + +try: + from PIL import Image # pylint: disable=import-error +except ModuleNotFoundError: + logger.info("No PIL installation found") + +if TYPE_CHECKING: + # pylint: disable=unused-import + from flask_appbuilder.security.sqla.models import User + from flask_caching import Cache + +# Time in seconds, we will wait for the page to load and render +SELENIUM_CHECK_INTERVAL = 2 +SELENIUM_RETRIES = 5 +SELENIUM_HEADSTART = 3 + +WindowSize = Tuple[int, int] + + +def get_auth_cookies(user: "User") -> List[Dict]: + # Login with the user specified to get the reports + with current_app.test_request_context("/login"): + login_user(user) + # A mock response object to get the cookie information from + response = Response() + current_app.session_interface.save_session(current_app, session, response) + + cookies = [] + + # Set the cookies in the driver + for name, value in response.headers: + if name.lower() == "set-cookie": + cookie = parse_cookie(value) + cookies.append(cookie["session"]) + return cookies + + +def auth_driver(driver: WebDriver, user: "User") -> WebDriver: + """ + Default AuthDriverFuncType type that sets a session cookie flask-login style + :return: WebDriver + """ + if user: + # Set the cookies in the driver + for cookie in get_auth_cookies(user): + info = dict(name="session", value=cookie) + driver.add_cookie(info) + elif request.cookies: + cookies = request.cookies + for k, v in cookies.items(): + cookie = dict(name=k, value=v) + driver.add_cookie(cookie) + return driver + + +def headless_url(path: str) -> str: + return urllib.parse.urljoin(current_app.config.get("WEBDRIVER_BASEURL", ""), path) + + +def get_url_path(view: str, **kwargs) -> str: + with current_app.test_request_context(): + return headless_url(url_for(view, **kwargs)) + + +class AuthWebDriverProxy: + def __init__( + self, + driver_type: str, + window: Optional[WindowSize] = None, + auth_func: Optional[Callable] = None, + ): + self._driver_type = driver_type + self._window: WindowSize = window or (800, 600) + config_auth_func: Callable = current_app.config.get( + "WEBDRIVER_AUTH_FUNC", auth_driver + ) + self._auth_func: Callable = auth_func or config_auth_func + + def create(self) -> WebDriver: + if self._driver_type == "firefox": + driver_class = firefox.webdriver.WebDriver + options = firefox.options.Options() + elif self._driver_type == "chrome": + driver_class = chrome.webdriver.WebDriver + options = chrome.options.Options() + arg: str = f"--window-size={self._window[0]},{self._window[1]}" + options.add_argument(arg) + else: + raise Exception(f"Webdriver name ({self._driver_type}) not supported") + # Prepare args for the webdriver init + options.add_argument("--headless") + kwargs: Dict = dict(options=options) + kwargs.update(current_app.config["WEBDRIVER_CONFIGURATION"]) + logger.info("Init selenium driver") + return driver_class(**kwargs) + + def auth(self, user: "User") -> WebDriver: + # Setting cookies requires doing a request first + driver = self.create() + driver.get(headless_url("/login/")) + return self._auth_func(driver, user) + + @staticmethod + def destroy(driver: WebDriver, tries=2): + """Destroy a driver""" + # This is some very flaky code in selenium. Hence the retries + # and catch-all exceptions + try: + retry_call(driver.close, tries=tries) + except Exception: # pylint: disable=broad-except + pass + try: + driver.quit() + except Exception: # pylint: disable=broad-except + pass + + def get_screenshot( + self, url: str, element_name: str, user: "User", retries: int = SELENIUM_RETRIES + ) -> Optional[bytes]: + driver = self.auth(user) + driver.set_window_size(*self._window) + driver.get(url) + img: Optional[bytes] = None + logger.debug(f"Sleeping for {SELENIUM_HEADSTART} seconds") + time.sleep(SELENIUM_HEADSTART) + try: + logger.debug(f"Wait for the presence of {element_name}") + element = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.CLASS_NAME, element_name)) + ) + logger.debug(f"Wait for .loading to be done") + WebDriverWait(driver, 60).until_not( + EC.presence_of_all_elements_located((By.CLASS_NAME, "loading")) + ) + logger.info("Taking a PNG screenshot") + img = element.screenshot_as_png + except TimeoutException: + logger.error("Selenium timed out") + except WebDriverException as ex: + logger.error(ex) + # Some webdrivers do not support screenshots for elements. + # In such cases, take a screenshot of the entire page. + img = driver.screenshot() # pylint: disable=no-member + finally: + self.destroy(driver, retries) + return img + + +class BaseScreenshot: + driver_type = "chrome" + thumbnail_type: str = "" + element: str = "" + window_size: WindowSize = (800, 600) + thumb_size: WindowSize = (400, 300) + + def __init__(self, model_id: int): + self.model_id: int = model_id + self.screenshot: Optional[bytes] = None + self._driver = AuthWebDriverProxy(self.driver_type, self.window_size) + + @property + def cache_key(self) -> str: + return f"thumb__{self.thumbnail_type}__{self.model_id}" + + @property + def url(self) -> str: + raise NotImplementedError() + + def get_screenshot(self, user: "User") -> Optional[bytes]: + self.screenshot = self._driver.get_screenshot(self.url, self.element, user) + return self.screenshot + + def get( + self, + user: "User" = None, + cache: "Cache" = None, + thumb_size: Optional[WindowSize] = None, + ) -> Optional[BytesIO]: + """ + Get thumbnail screenshot has BytesIO from cache or fetch + + :param user: None to use current user or User Model to login and fetch + :param cache: The cache to use + :param thumb_size: Override thumbnail site + """ + payload: Optional[bytes] = None + thumb_size = thumb_size or self.thumb_size + if cache: + payload = cache.get(self.cache_key) + if not payload: + payload = self.compute_and_cache( + user=user, thumb_size=thumb_size, cache=cache + ) + else: + logger.info(f"Loaded thumbnail from cache: {self.cache_key}") + if payload: + return BytesIO(payload) + return None + + def get_from_cache(self, cache: "Cache") -> Optional[BytesIO]: + payload = cache.get(self.cache_key) + if payload: + return BytesIO(payload) + return None + + def compute_and_cache( # pylint: disable=too-many-arguments + self, + user: "User" = None, + thumb_size: Optional[WindowSize] = None, + cache: "Cache" = None, + force: bool = True, + ) -> Optional[bytes]: + """ + Fetches the screenshot, computes the thumbnail and caches the result + + :param user: If no user is given will use the current context + :param cache: The cache to keep the thumbnail payload + :param window_size: The window size from which will process the thumb + :param thumb_size: The final thumbnail size + :param force: Will force the computation even if it's already cached + :return: Image payload + """ + cache_key = self.cache_key + if not force and cache and cache.get(cache_key): + logger.info("Thumb already cached, skipping...") + return None + thumb_size = thumb_size or self.thumb_size + logger.info(f"Processing url for thumbnail: {cache_key}") + + payload = None + + # Assuming all sorts of things can go wrong with Selenium + try: + payload = self.get_screenshot(user=user) + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed at generating thumbnail %s", ex) + + if payload and self.window_size != thumb_size: + try: + payload = self.resize_image(payload, thumb_size=thumb_size) + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed at resizing thumbnail %s", ex) + payload = None + + if payload and cache: + logger.info(f"Caching thumbnail: {cache_key} {cache}") + cache.set(cache_key, payload) + return payload + + @classmethod + def resize_image( + cls, + img_bytes: bytes, + output: str = "png", + thumb_size: Optional[WindowSize] = None, + crop: bool = True, + ) -> bytes: + thumb_size = thumb_size or cls.thumb_size + img = Image.open(BytesIO(img_bytes)) + logger.debug(f"Selenium image size: {img.size}") + if crop and img.size[1] != cls.window_size[1]: + desired_ratio = float(cls.window_size[1]) / cls.window_size[0] + desired_width = int(img.size[0] * desired_ratio) + logger.debug(f"Cropping to: {img.size[0]}*{desired_width}") + img = img.crop((0, 0, img.size[0], desired_width)) + logger.debug(f"Resizing to {thumb_size}") + img = img.resize(thumb_size, Image.ANTIALIAS) + new_img = BytesIO() + if output != "png": + img = img.convert("RGB") + img.save(new_img, output) + new_img.seek(0) + return new_img.read() + + +class ChartScreenshot(BaseScreenshot): + thumbnail_type: str = "chart" + element: str = "chart-container" + window_size: WindowSize = (600, int(600 * 0.75)) + thumb_size: WindowSize = (300, int(300 * 0.75)) + + @property + def url(self) -> str: + return get_url_path("Superset.slice", slice_id=self.model_id, standalone="true") + + +class DashboardScreenshot(BaseScreenshot): + thumbnail_type: str = "dashboard" + element: str = "grid-container" + window_size: WindowSize = (1600, int(1600 * 0.75)) + thumb_size: WindowSize = (400, int(400 * 0.75)) + + @property + def url(self) -> str: + return get_url_path("Superset.dashboard", dashboard_id=self.model_id) diff --git a/superset/views/annotations.py b/superset/views/annotations.py index 5ba975050230..6d8797267453 100644 --- a/superset/views/annotations.py +++ b/superset/views/annotations.py @@ -95,7 +95,7 @@ class AnnotationLayerModelView( SupersetModelView, DeleteMixin ): # pylint: disable=too-many-ancestors datamodel = SQLAInterface(AnnotationLayer) - include_route_methods = RouteMethod.CRUD_SET + include_route_methods = RouteMethod.CRUD_SET | {RouteMethod.API_READ} list_title = _("List Annotation Layer") show_title = _("Show Annotation Layer") diff --git a/superset/views/base.py b/superset/views/base.py index 75d6d54d3d13..33ca79148e4a 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -18,7 +18,7 @@ import logging import traceback from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import simplejson as json import yaml @@ -27,6 +27,7 @@ from flask_appbuilder.actions import action from flask_appbuilder.forms import DynamicForm from flask_appbuilder.models.sqla.filters import BaseFilter +from flask_appbuilder.security.sqla.models import Role, User from flask_appbuilder.widgets import ListWidget from flask_babel import get_locale, gettext as __, lazy_gettext as _ from flask_wtf.form import FlaskForm @@ -34,7 +35,15 @@ from werkzeug.exceptions import HTTPException from wtforms.fields.core import Field, UnboundField -from superset import appbuilder, conf, db, get_feature_flags, security_manager +from superset import ( + app as superset_app, + appbuilder, + conf, + db, + get_feature_flags, + security_manager, +) +from superset.connectors.sqla import models from superset.exceptions import SupersetException, SupersetSecurityException from superset.translations.utils import get_language_pack from superset.utils import core as utils @@ -53,6 +62,9 @@ ) logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) +config = superset_app.config + def get_error_msg(): if conf.get("SHOW_STACKTRACE"): @@ -88,7 +100,9 @@ def data_payload_response(payload_json, has_error=False): return json_success(payload_json, status=status) -def generate_download_headers(extension, filename=None): +def generate_download_headers( + extension: str, filename: Optional[str] = None +) -> Dict[str, Any]: filename = filename if filename else datetime.now().strftime("%Y%m%d_%H%M%S") content_disp = f"attachment; filename={filename}.{extension}" headers = {"Content-Disposition": content_disp} @@ -104,8 +118,8 @@ def api(f): def wraps(self, *args, **kwargs): try: return f(self, *args, **kwargs) - except Exception as e: # pylint: disable=broad-except - logger.exception(e) + except Exception as ex: # pylint: disable=broad-except + logger.exception(ex) return json_error_response(get_error_msg()) return functools.update_wrapper(wraps, f) @@ -121,31 +135,65 @@ def handle_api_exception(f): def wraps(self, *args, **kwargs): try: return f(self, *args, **kwargs) - except SupersetSecurityException as e: - logger.exception(e) + except SupersetSecurityException as ex: + logger.exception(ex) return json_error_response( - utils.error_msg_from_exception(e), status=e.status, link=e.link + utils.error_msg_from_exception(ex), status=ex.status, link=ex.link ) - except SupersetException as e: - logger.exception(e) + except SupersetException as ex: + logger.exception(ex) return json_error_response( - utils.error_msg_from_exception(e), status=e.status + utils.error_msg_from_exception(ex), status=ex.status ) - except HTTPException as e: - logger.exception(e) - return json_error_response(utils.error_msg_from_exception(e), status=e.code) - except Exception as e: # pylint: disable=broad-except - logger.exception(e) - return json_error_response(utils.error_msg_from_exception(e)) + except HTTPException as ex: + logger.exception(ex) + return json_error_response( + utils.error_msg_from_exception(ex), status=ex.code + ) + except Exception as ex: # pylint: disable=broad-except + logger.exception(ex) + return json_error_response(utils.error_msg_from_exception(ex)) return functools.update_wrapper(wraps, f) -def get_datasource_exist_error_msg(full_name): +def get_datasource_exist_error_msg(full_name: str) -> str: return __("Datasource %(name)s already exists", name=full_name) -def get_user_roles(): +def validate_sqlatable(table: models.SqlaTable) -> None: + """Checks the table existence in the database.""" + with db.session.no_autoflush: + table_query = db.session.query(models.SqlaTable).filter( + models.SqlaTable.table_name == table.table_name, + models.SqlaTable.schema == table.schema, + models.SqlaTable.database_id == table.database.id, + ) + if db.session.query(table_query.exists()).scalar(): + raise Exception(get_datasource_exist_error_msg(table.full_name)) + + # Fail before adding if the table can't be found + try: + table.get_sqla_table_object() + except Exception as ex: + logger.exception(f"Got an error in pre_add for {table.name}") + raise Exception( + _( + "Table [%{table}s] could not be found, " + "please double check your " + "database connection, schema, and " + "table name, error: {}" + ).format(table.name, str(ex)) + ) + + +def create_table_permissions(table: models.SqlaTable) -> None: + security_manager.add_permission_view_menu("datasource_access", table.get_perm()) + if table.schema: + security_manager.add_permission_view_menu("schema_access", table.schema_perm) + + +def get_user_roles() -> List[Role]: if g.user.is_anonymous: public_role = conf.get("AUTH_ROLE_PUBLIC") return [security_manager.find_role(public_role)] if public_role else [] @@ -173,8 +221,8 @@ def menu_data(): or f"/profile/{g.user.username}/" ) # when user object has no username - except NameError as e: - logger.exception(e) + except NameError as ex: + logger.exception(ex) if logo_target_path.startswith("/"): root_path = f"/superset{logo_target_path}" @@ -258,8 +306,8 @@ class ListWidgetWithCheckboxes(ListWidget): # pylint: disable=too-few-public-me def validate_json(_form, field): try: json.loads(field.data) - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) raise Exception(_("json isn't valid")) @@ -300,8 +348,8 @@ def _delete(self, primary_key): abort(404) try: self.pre_delete(item) - except Exception as e: # pylint: disable=broad-except - flash(str(e), "danger") + except Exception as ex: # pylint: disable=broad-except + flash(str(ex), "danger") else: view_menu = security_manager.find_view_menu(item.get_perm()) pvs = ( @@ -335,8 +383,8 @@ def muldelete(self, items): for item in items: try: self.pre_delete(item) - except Exception as e: # pylint: disable=broad-except - flash(str(e), "danger") + except Exception as ex: # pylint: disable=broad-except + flash(str(ex), "danger") else: self._delete(item.id) self.update_redirect() @@ -365,7 +413,7 @@ class CsvResponse(Response): # pylint: disable=too-many-ancestors charset = conf["CSV_EXPORT"].get("encoding", "utf-8") -def check_ownership(obj, raise_if_false=True): +def check_ownership(obj: Any, raise_if_false: bool = True) -> bool: """Meant to be used in `pre_update` hooks on models to enforce ownership Admin have all access, and other users need to be referenced on either @@ -392,7 +440,7 @@ def check_ownership(obj, raise_if_false=True): orig_obj = scoped_session.query(obj.__class__).filter_by(id=obj.id).first() # Making a list of owners that works across ORM models - owners = [] + owners: List[User] = [] if hasattr(orig_obj, "owners"): owners += orig_obj.owners if hasattr(orig_obj, "owner"): diff --git a/superset/views/base_api.py b/superset/views/base_api.py index fdfafe1707b0..60f9d29527c8 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -16,16 +16,16 @@ # under the License. import functools import logging -from typing import Dict, Set, Tuple +from typing import Any, cast, Dict, Optional, Set, Tuple, Type, Union -from flask import request +from flask import Response from flask_appbuilder import ModelRestApi from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.filters import BaseFilter, Filters -from sqlalchemy.exc import SQLAlchemyError +from flask_appbuilder.models.sqla.filters import FilterStartsWith -from superset.exceptions import SupersetSecurityException -from superset.views.base import check_ownership +from superset.stats_logger import BaseStatsLogger +from superset.utils.core import time_function logger = logging.getLogger(__name__) get_related_schema = { @@ -38,26 +38,27 @@ } -def check_ownership_and_item_exists(f): +def statsd_metrics(f): """ - A Decorator that checks if an object exists and is owned by the current user + Handle sending all statsd metrics from the REST API """ - def wraps(self, pk): # pylint: disable=invalid-name - item = self.datamodel.get( - pk, self._base_filters # pylint: disable=protected-access - ) - if not item: - return self.response_404() - try: - check_ownership(item) - except SupersetSecurityException as e: - return self.response(403, message=str(e)) - return f(self, item) + def wraps(self, *args: Any, **kwargs: Any) -> Response: + duration, response = time_function(f, self, *args, **kwargs) + self.send_stats_metrics(response, f.__name__, duration) + return response return functools.update_wrapper(wraps, f) +class RelatedFieldFilter: + # data class to specify what filter to use on a /related endpoint + # pylint: disable=too-few-public-methods + def __init__(self, field_name: str, filter_class: Type[BaseFilter]): + self.field_name = field_name + self.filter_class = filter_class + + class BaseSupersetModelRestApi(ModelRestApi): """ Extends FAB's ModelResApi to implement specific superset generic functionality @@ -74,6 +75,11 @@ class BaseSupersetModelRestApi(ModelRestApi): "bulk_delete": "delete", "info": "list", "related": "list", + "thumbnail": "list", + "refresh": "edit", + "data": "list", + "viz_types": "list", + "datasources": "list", } order_rel_fields: Dict[str, Tuple[str, str]] = {} @@ -85,12 +91,12 @@ class BaseSupersetModelRestApi(ModelRestApi): ... } """ # pylint: disable=pointless-string-statement - filter_rel_fields_field: Dict[str, str] = {} + related_field_filters: Dict[str, Union[RelatedFieldFilter, str]] = {} """ - Declare the related field field for filtering:: + Declare the filters for related fields:: - filter_rel_fields_field = { - "<RELATED_FIELD>": "<RELATED_FIELD_FIELD>") + related_fields = { + "<RELATED_FIELD>": <RelatedFieldFilter>) } """ # pylint: disable=pointless-string-statement filter_rel_fields: Dict[str, BaseFilter] = {} @@ -103,9 +109,9 @@ class BaseSupersetModelRestApi(ModelRestApi): """ # pylint: disable=pointless-string-statement allowed_rel_fields: Set[str] = set() - def __init__(self): + def __init__(self) -> None: super().__init__() - self.stats_logger = None + self.stats_logger = BaseStatsLogger() def create_blueprint(self, appbuilder, *args, **kwargs): self.stats_logger = self.appbuilder.get_app.config["STATS_LOGGER"] @@ -124,23 +130,87 @@ def _init_properties(self): super()._init_properties() def _get_related_filter(self, datamodel, column_name: str, value: str) -> Filters: - filter_field = self.filter_rel_fields_field.get(column_name) - filters = datamodel.get_filters([filter_field]) + filter_field = self.related_field_filters.get(column_name) + if isinstance(filter_field, str): + filter_field = RelatedFieldFilter(cast(str, filter_field), FilterStartsWith) + filter_field = cast(RelatedFieldFilter, filter_field) + search_columns = [filter_field.field_name] if filter_field else None + filters = datamodel.get_filters(search_columns) base_filters = self.filter_rel_fields.get(column_name) if base_filters: - filters = filters.add_filter_list(base_filters) - if value: - filters.rest_add_filters( - [{"opr": "sw", "col": filter_field, "value": value}] + filters.add_filter_list(base_filters) + if value and filter_field: + filters.add_filter( + filter_field.field_name, filter_field.filter_class, value ) return filters def incr_stats(self, action: str, func_name: str) -> None: + """ + Proxy function for statsd.incr to impose a key structure for REST API's + + :param action: String with an action name eg: error, success + :param func_name: The function name + """ self.stats_logger.incr(f"{self.__class__.__name__}.{func_name}.{action}") + def timing_stats(self, action: str, func_name: str, value: float) -> None: + """ + Proxy function for statsd.incr to impose a key structure for REST API's + + :param action: String with an action name eg: error, success + :param func_name: The function name + :param value: A float with the time it took for the endpoint to execute + """ + self.stats_logger.timing( + f"{self.__class__.__name__}.{func_name}.{action}", value + ) + + def send_stats_metrics( + self, response: Response, key: str, time_delta: Optional[float] = None + ) -> None: + """ + Helper function to handle sending statsd metrics + + :param response: flask response object, will evaluate if it was an error + :param key: The function name + :param time_delta: Optional time it took for the endpoint to execute + """ + if 200 <= response.status_code < 400: + self.incr_stats("success", key) + else: + self.incr_stats("error", key) + if time_delta: + self.timing_stats("time", key, time_delta) + + def info_headless(self, **kwargs) -> Response: + """ + Add statsd metrics to builtin FAB _info endpoint + """ + duration, response = time_function(super().info_headless, **kwargs) + self.send_stats_metrics(response, self.info.__name__, duration) + return response + + def get_headless(self, pk, **kwargs) -> Response: + """ + Add statsd metrics to builtin FAB GET endpoint + """ + duration, response = time_function(super().get_headless, pk, **kwargs) + self.send_stats_metrics(response, self.get.__name__, duration) + return response + + def get_list_headless(self, **kwargs) -> Response: + """ + Add statsd metrics to builtin FAB GET list endpoint + """ + duration, response = time_function(super().get_list_headless, **kwargs) + self.send_stats_metrics(response, self.get_list.__name__, duration) + return response + @expose("/related/<column_name>", methods=["GET"]) @protect() @safe + @statsd_metrics @rison(get_related_schema) def related(self, column_name: str, **kwargs): """Get related fields data @@ -193,6 +263,7 @@ def related(self, column_name: str, **kwargs): $ref: '#/components/responses/500' """ if column_name not in self.allowed_rel_fields: + self.incr_stats("error", self.related.__name__) return self.response_404() args = kwargs.get("rison", {}) # handle pagination @@ -220,155 +291,3 @@ def related(self, column_name: str, **kwargs): for value in values ] return self.response(200, count=count, result=result) - - -class BaseOwnedModelRestApi(BaseSupersetModelRestApi): - @expose("/<pk>", methods=["PUT"]) - @protect() - @check_ownership_and_item_exists - @safe - def put(self, item): # pylint: disable=arguments-differ - """Changes a owned Model - --- - put: - parameters: - - in: path - schema: - type: integer - name: pk - requestBody: - description: Model schema - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/{{self.__class__.__name__}}.put' - responses: - 200: - description: Item changed - content: - application/json: - schema: - type: object - properties: - result: - $ref: '#/components/schemas/{{self.__class__.__name__}}.put' - 400: - $ref: '#/components/responses/400' - 401: - $ref: '#/components/responses/401' - 403: - $ref: '#/components/responses/401' - 404: - $ref: '#/components/responses/404' - 422: - $ref: '#/components/responses/422' - 500: - $ref: '#/components/responses/500' - """ - if not request.is_json: - self.response_400(message="Request is not JSON") - item = self.edit_model_schema.load(request.json, instance=item) - if item.errors: - return self.response_422(message=item.errors) - try: - self.datamodel.edit(item.data, raise_exception=True) - return self.response( - 200, result=self.edit_model_schema.dump(item.data, many=False).data - ) - except SQLAlchemyError as e: - logger.error(f"Error updating model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) - - @expose("/", methods=["POST"]) - @protect() - @safe - def post(self): - """Creates a new owned Model - --- - post: - requestBody: - description: Model schema - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/{{self.__class__.__name__}}.post' - responses: - 201: - description: Model added - content: - application/json: - schema: - type: object - properties: - id: - type: string - result: - $ref: '#/components/schemas/{{self.__class__.__name__}}.post' - 400: - $ref: '#/components/responses/400' - 401: - $ref: '#/components/responses/401' - 422: - $ref: '#/components/responses/422' - 500: - $ref: '#/components/responses/500' - """ - if not request.is_json: - return self.response_400(message="Request is not JSON") - item = self.add_model_schema.load(request.json) - # This validates custom Schema with custom validations - if item.errors: - return self.response_422(message=item.errors) - try: - self.datamodel.add(item.data, raise_exception=True) - return self.response( - 201, - result=self.add_model_schema.dump(item.data, many=False).data, - id=item.data.id, - ) - except SQLAlchemyError as e: - logger.error(f"Error creating model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) - - @expose("/<pk>", methods=["DELETE"]) - @protect() - @check_ownership_and_item_exists - @safe - def delete(self, item): # pylint: disable=arguments-differ - """Deletes owned Model - --- - delete: - parameters: - - in: path - schema: - type: integer - name: pk - responses: - 200: - description: Model delete - content: - application/json: - schema: - type: object - properties: - message: - type: string - 401: - $ref: '#/components/responses/401' - 403: - $ref: '#/components/responses/401' - 404: - $ref: '#/components/responses/404' - 422: - $ref: '#/components/responses/422' - 500: - $ref: '#/components/responses/500' - """ - try: - self.datamodel.delete(item, raise_exception=True) - return self.response(200, message="OK") - except SQLAlchemyError as e: - logger.error(f"Error deleting model {self.__class__.__name__}: {e}") - return self.response_422(message=str(e)) diff --git a/superset/views/chart/api.py b/superset/views/chart/api.py deleted file mode 100644 index bd211815270e..000000000000 --- a/superset/views/chart/api.py +++ /dev/null @@ -1,182 +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. -from typing import Dict, List, Optional - -from flask import current_app -from flask_appbuilder.models.sqla.interface import SQLAInterface -from marshmallow import fields, post_load, validates_schema, ValidationError -from marshmallow.validate import Length -from sqlalchemy.orm.exc import NoResultFound - -from superset.connectors.connector_registry import ConnectorRegistry -from superset.exceptions import SupersetException -from superset.models.dashboard import Dashboard -from superset.models.slice import Slice -from superset.utils import core as utils -from superset.views.base_api import BaseOwnedModelRestApi -from superset.views.base_schemas import BaseOwnedSchema, validate_owner -from superset.views.chart.mixin import SliceMixin - - -def validate_json(value): - try: - utils.validate_json(value) - except SupersetException: - raise ValidationError("JSON not valid") - - -def validate_dashboard(value): - try: - (current_app.appbuilder.get_session.query(Dashboard).filter_by(id=value).one()) - except NoResultFound: - raise ValidationError(f"Dashboard {value} does not exist") - - -def validate_update_datasource(data: Dict): - if not ("datasource_type" in data and "datasource_id" in data): - return - datasource_type = data["datasource_type"] - datasource_id = data["datasource_id"] - try: - datasource = ConnectorRegistry.get_datasource( - datasource_type, datasource_id, current_app.appbuilder.get_session - ) - except (NoResultFound, KeyError): - raise ValidationError( - f"Datasource [{datasource_type}].{datasource_id} does not exist" - ) - data["datasource_name"] = datasource.name - - -def populate_dashboards(instance: Slice, dashboards: List[int]): - """ - Mutates a Slice with the dashboards SQLA Models - """ - dashboards_tmp = [] - for dashboard_id in dashboards: - dashboards_tmp.append( - current_app.appbuilder.get_session.query(Dashboard) - .filter_by(id=dashboard_id) - .one() - ) - instance.dashboards = dashboards_tmp - - -class ChartPostSchema(BaseOwnedSchema): - __class_model__ = Slice - - slice_name = fields.String(required=True, validate=Length(1, 250)) - description = fields.String(allow_none=True) - viz_type = fields.String(allow_none=True, validate=Length(0, 250)) - owners = fields.List(fields.Integer(validate=validate_owner)) - params = fields.String(allow_none=True, validate=validate_json) - cache_timeout = fields.Integer(allow_none=True) - datasource_id = fields.Integer(required=True) - datasource_type = fields.String(required=True) - datasource_name = fields.String(allow_none=True) - dashboards = fields.List(fields.Integer(validate=validate_dashboard)) - - @validates_schema - def validate_schema(self, data: Dict): # pylint: disable=no-self-use - validate_update_datasource(data) - - @post_load - def make_object(self, data: Dict, discard: Optional[List[str]] = None) -> Slice: - instance = super().make_object(data, discard=["dashboards"]) - populate_dashboards(instance, data.get("dashboards", [])) - return instance - - -class ChartPutSchema(BaseOwnedSchema): - instance: Slice - - slice_name = fields.String(allow_none=True, validate=Length(0, 250)) - description = fields.String(allow_none=True) - viz_type = fields.String(allow_none=True, validate=Length(0, 250)) - owners = fields.List(fields.Integer(validate=validate_owner)) - params = fields.String(allow_none=True) - cache_timeout = fields.Integer(allow_none=True) - datasource_id = fields.Integer(allow_none=True) - datasource_type = fields.String(allow_none=True) - dashboards = fields.List(fields.Integer(validate=validate_dashboard)) - - @validates_schema - def validate_schema(self, data: Dict): # pylint: disable=no-self-use - validate_update_datasource(data) - - @post_load - def make_object(self, data: Dict, discard: Optional[List[str]] = None) -> Slice: - self.instance = super().make_object(data, ["dashboards"]) - if "dashboards" in data: - populate_dashboards(self.instance, data["dashboards"]) - return self.instance - - -class ChartRestApi(SliceMixin, BaseOwnedModelRestApi): - datamodel = SQLAInterface(Slice) - - resource_name = "chart" - allow_browser_login = True - - class_permission_name = "SliceModelView" - show_columns = [ - "slice_name", - "description", - "owners.id", - "owners.username", - "dashboards.id", - "dashboards.dashboard_title", - "viz_type", - "params", - "cache_timeout", - ] - list_columns = [ - "id", - "slice_name", - "url", - "description", - "changed_by.username", - "changed_by_name", - "changed_by_url", - "changed_on", - "datasource_name_text", - "datasource_url", - "viz_type", - "params", - "cache_timeout", - ] - order_columns = [ - "slice_name", - "viz_type", - "datasource_name", - "changed_by_fk", - "changed_on", - ] - - # Will just affect _info endpoint - edit_columns = ["slice_name"] - add_columns = edit_columns - - add_model_schema = ChartPostSchema() - edit_model_schema = ChartPutSchema() - - order_rel_fields = { - "slices": ("slice_name", "asc"), - "owners": ("first_name", "asc"), - } - filter_rel_fields_field = {"owners": "first_name"} - allowed_rel_fields = {"owners"} diff --git a/superset/views/core.py b/superset/views/core.py index 447461ef3f74..30eb6fe279de 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -67,6 +67,7 @@ from superset.connectors.sqla.models import AnnotationDatasource from superset.constants import RouteMethod from superset.exceptions import ( + CertificateException, DatabaseNotFound, SupersetException, SupersetSecurityException, @@ -97,6 +98,7 @@ BaseSupersetView, check_ownership, common_bootstrap_payload, + create_table_permissions, CsvResponse, data_payload_response, DeleteMixin, @@ -107,6 +109,7 @@ json_error_response, json_success, SupersetModelView, + validate_sqlatable, ) from .utils import ( apply_display_max_row_limit, @@ -184,8 +187,11 @@ def check_datasource_perms( datasource_id, datasource_type = get_datasource_info( datasource_id, datasource_type, form_data ) - except SupersetException as e: - raise SupersetSecurityException(str(e)) + except SupersetException as ex: + raise SupersetSecurityException(str(ex)) + + if datasource_type is None: + raise SupersetSecurityException("Could not determine datasource type") viz_obj = get_viz( datasource_type=datasource_type, @@ -313,8 +319,8 @@ def store(self): obj = models.KeyValue(value=value) db.session.add(obj) db.session.commit() - except Exception as e: - return json_error_response(e) + except Exception as ex: + return json_error_response(ex) return Response(json.dumps({"id": obj.id}), status=200) @event_logger.log_this @@ -325,8 +331,8 @@ def get_value(self, key_id): kv = db.session.query(models.KeyValue).filter_by(id=key_id).scalar() if not kv: return Response(status=404, content_type="text/plain") - except Exception as e: - return json_error_response(e) + except Exception as ex: + return json_error_response(ex) return Response(kv.value, status=200, content_type="text/plain") @@ -576,27 +582,6 @@ def clean_fulfilled_requests(session): session.commit() return redirect("/accessrequestsmodelview/list/") - def get_viz( - self, - slice_id=None, - form_data=None, - datasource_type=None, - datasource_id=None, - force=False, - ): - if slice_id: - slc = db.session.query(Slice).filter_by(id=slice_id).one() - return slc.get_viz() - else: - viz_type = form_data.get("viz_type", "table") - datasource = ConnectorRegistry.get_datasource( - datasource_type, datasource_id, db.session - ) - viz_obj = viz.viz_types[viz_type]( - datasource, form_data=form_data, force=force - ) - return viz_obj - @has_access @expose("/slice/<slice_id>/") def slice(self, slice_id): @@ -617,9 +602,9 @@ def get_query_string_response(self, viz_obj): query_obj = viz_obj.query_obj() if query_obj: query = viz_obj.datasource.get_query_str(query_obj) - except Exception as e: - logger.exception(e) - return json_error_response(e) + except Exception as ex: + logger.exception(ex) + return json_error_response(ex) if not query: query = "No query." @@ -723,8 +708,8 @@ def explore_json(self, datasource_type=None, datasource_id=None): datasource_id, datasource_type = get_datasource_info( datasource_id, datasource_type, form_data ) - except SupersetException as e: - return json_error_response(utils.error_msg_from_exception(e)) + except SupersetException as ex: + return json_error_response(utils.error_msg_from_exception(ex)) viz_obj = get_viz( datasource_type=datasource_type, @@ -746,19 +731,19 @@ def import_dashboards(self): if request.method == "POST" and f: try: dashboard_import_export.import_dashboards(db.session, f.stream) - except DatabaseNotFound as e: - logger.exception(e) + except DatabaseNotFound as ex: + logger.exception(ex) flash( _( "Cannot import dashboard: %(db_error)s.\n" "Make sure to create the database before " "importing the dashboard.", - db_error=e, + db_error=ex, ), "danger", ) - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) flash( _( "An unknown error occurred. " @@ -946,6 +931,12 @@ def filter(self, datasource_type, datasource_id, column): ) return json_success(payload) + @staticmethod + def remove_extra_filters(filters): + """Extra filters are ones inherited from the dashboard's temporary context + Those should not be saved when saving the chart""" + return [f for f in filters if not f.get("isExtra")] + def save_or_overwrite_slice( self, args, @@ -967,6 +958,10 @@ def save_or_overwrite_slice( form_data.pop("slice_id") # don't save old slice_id slc = Slice(owners=[g.user] if g.user else []) + form_data["adhoc_filters"] = self.remove_extra_filters( + form_data.get("adhoc_filters", []) + ) + slc.params = json.dumps(form_data, indent=2, sort_keys=True) slc.datasource_name = datasource_name slc.viz_type = form_data["viz_type"] @@ -1364,6 +1359,7 @@ def testconn(self): # this is the database instance that will be tested database = models.Database( # extras is sent as json, but required to be a string in the Database model + server_cert=request.json.get("server_cert"), extra=json.dumps(request.json.get("extras", {})), impersonate_user=request.json.get("impersonate_user"), encrypted_extra=json.dumps(request.json.get("encrypted_extra", {})), @@ -1377,8 +1373,11 @@ def testconn(self): with closing(engine.connect()) as conn: conn.scalar(select([1])) return json_success('"OK"') - except NoSuchModuleError as e: - logger.info("Invalid driver %s", e) + except CertificateException as ex: + logger.info(ex.message) + return json_error_response(ex.message) + except NoSuchModuleError as ex: + logger.info("Invalid driver %s", ex) driver_name = make_url(uri).drivername return json_error_response( _( @@ -1387,24 +1386,24 @@ def testconn(self): ), 400, ) - except ArgumentError as e: - logger.info("Invalid URI %s", e) + except ArgumentError as ex: + logger.info("Invalid URI %s", ex) return json_error_response( _( "Invalid connection string, a valid string usually follows:\n" "'DRIVER://USER:PASSWORD@DB-HOST/DATABASE-NAME'" ) ) - except OperationalError as e: - logger.warning("Connection failed %s", e) + except OperationalError as ex: + logger.warning("Connection failed %s", ex) return json_error_response( _("Connection failed, please check your connection settings."), 400 ) - except DBSecurityException as e: - logger.warning("Stopped an unsafe database connection. %s", e) - return json_error_response(_(str(e)), 400) - except Exception as e: - logger.error("Unexpected error %s", e) + except DBSecurityException as ex: + logger.warning("Stopped an unsafe database connection. %s", ex) + return json_error_response(_(str(ex)), 400) + except Exception as ex: + logger.error("Unexpected error %s", ex) return json_error_response( _("Unexpected error occurred, please check your logs for details"), 400 ) @@ -1695,6 +1694,8 @@ def warm_up_cache(self): .all() ) + result = [] + for slc in slices: try: form_data = get_form_data(slc.id, use_slice_data=True)[0] @@ -1708,15 +1709,18 @@ def warm_up_cache(self): form_data=form_data, force=True, ) - obj.get_json() - except Exception as e: - logger.exception("Failed to warm up cache") - return json_error_response(utils.error_msg_from_exception(e)) - return json_success( - json.dumps( - [{"slice_id": slc.id, "slice_name": slc.slice_name} for slc in slices] + payload = obj.get_payload() + error = payload["error"] + status = payload["status"] + except Exception as ex: + error = utils.error_msg_from_exception(ex) + status = None + + result.append( + {"slice_id": slc.id, "viz_error": error, "viz_status": status} ) - ) + + return json_success(json.dumps(result)) @has_access_api @expose("/favstar/<class_name>/<obj_id>/<action>/") @@ -1953,11 +1957,53 @@ def sync_druid_source(self): return json_error_response(err_msg) try: DruidDatasource.sync_to_db_from_config(druid_config, user, cluster) - except Exception as e: - logger.exception(utils.error_msg_from_exception(e)) - return json_error_response(utils.error_msg_from_exception(e)) + except Exception as ex: + logger.exception(utils.error_msg_from_exception(ex)) + return json_error_response(utils.error_msg_from_exception(ex)) return Response(status=201) + @has_access + @expose("/get_or_create_table/", methods=["POST"]) + @event_logger.log_this + def sqllab_table_viz(self): + """ Gets or creates a table object with attributes passed to the API. + + It expects the json with params: + * datasourceName - e.g. table name, required + * dbId - database id, required + * schema - table schema, optional + * templateParams - params for the Jinja templating syntax, optional + :return: Response + """ + SqlaTable = ConnectorRegistry.sources["table"] + data = json.loads(request.form.get("data")) + table_name = data.get("datasourceName") + database_id = data.get("dbId") + table = ( + db.session.query(SqlaTable) + .filter_by(database_id=database_id, table_name=table_name) + .one_or_none() + ) + if not table: + # Create table if doesn't exist. + with db.session.no_autoflush: + table = SqlaTable(table_name=table_name, owners=[g.user]) + table.database_id = database_id + table.database = ( + db.session.query(models.Database).filter_by(id=database_id).one() + ) + table.schema = data.get("schema") + table.template_params = data.get("templateParams") + # needed for the table validation. + validate_sqlatable(table) + + db.session.add(table) + table.fetch_metadata() + create_table_permissions(table) + db.session.commit() + + return json_success(json.dumps({"table_id": table.id})) + @has_access @expose("/sqllab_viz/", methods=["POST"]) @event_logger.log_this @@ -2067,11 +2113,11 @@ def estimate_query_cost( cost = mydb.db_engine_spec.estimate_query_cost( mydb, schema, sql, utils.QuerySource.SQL_LAB ) - except SupersetTimeoutException as e: - logger.exception(e) + except SupersetTimeoutException as ex: + logger.exception(ex) return json_error_response(timeout_msg) - except Exception as e: - return json_error_response(str(e)) + except Exception as ex: + return json_error_response(str(ex)) spec = mydb.db_engine_spec query_cost_formatters = get_feature_flags().get( @@ -2229,15 +2275,15 @@ def validate_sql_json(self): encoding=None, ) return json_success(payload) - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) msg = _( f"{validator.name} was unable to check your query.\n" "Please recheck your query.\n" - f"Exception: {e}" + f"Exception: {ex}" ) # Return as a 400 if the database error message says we got a 4xx error - if re.search(r"([\W]|^)4\d{2}([\W]|$)", str(e)): + if re.search(r"([\W]|^)4\d{2}([\W]|$)", str(ex)): return json_error_response(f"{msg}", status=400) else: return json_error_response(f"{msg}") @@ -2271,8 +2317,8 @@ def _sql_json_async( expand_data=expand_data, log_params=log_params, ) - except Exception as e: - logger.exception(f"Query {query.id}: {e}") + except Exception as ex: + logger.exception(f"Query {query.id}: {ex}") msg = _( "Failed to start remote query on a worker. " "Tell your administrator to verify the availability of " @@ -2333,8 +2379,8 @@ def _sql_json_sync( ignore_nan=True, encoding=None, ) - except Exception as e: - logger.exception(f"Query {query.id}: {e}") + except Exception as ex: + logger.exception(f"Query {query.id}: {ex}") return json_error_response(f"{{e}}") if data.get("status") == QueryStatus.FAILED: return json_error_response(payload=data) @@ -2417,8 +2463,8 @@ def sql_json_exec( session.flush() query_id = query.id session.commit() # shouldn't be necessary - except SQLAlchemyError as e: - logger.error(f"Errors saving query details {e}") + except SQLAlchemyError as ex: + logger.error(f"Errors saving query details {ex}") session.rollback() raise Exception(_("Query record was not created as expected.")) if not query_id: @@ -2443,8 +2489,8 @@ def sql_json_exec( rendered_query = template_processor.process_template( query.sql, **template_params ) - except Exception as e: - error_msg = utils.error_msg_from_exception(e) + except Exception as ex: + error_msg = utils.error_msg_from_exception(ex) return json_error_response( f"Query {query_id}: Template rendering failed: {error_msg}" ) @@ -2703,7 +2749,7 @@ def profile(self, username): ) @staticmethod - def _get_sqllab_payload(user_id: int) -> Dict[str, Any]: + def _get_sqllab_tabs(user_id: int) -> Dict[str, Any]: # send list of tab state ids tabs_state = ( db.session.query(TabState.id, TabState.label) @@ -2743,8 +2789,6 @@ def _get_sqllab_payload(user_id: int) -> Dict[str, Any]: } return { - "defaultDbId": config["SQLLAB_DEFAULT_DBID"], - "common": common_bootstrap_payload(), "tab_state_ids": tabs_state, "active_tab": active_tab.to_dict() if active_tab else None, "databases": databases, @@ -2752,10 +2796,21 @@ def _get_sqllab_payload(user_id: int) -> Dict[str, Any]: } @has_access - @expose("/sqllab") + @expose("/sqllab", methods=["GET", "POST"]) def sqllab(self): """SQL Editor""" - payload = self._get_sqllab_payload(g.user.get_id()) + payload = { + "defaultDbId": config["SQLLAB_DEFAULT_DBID"], + "common": common_bootstrap_payload(), + **self._get_sqllab_tabs(g.user.get_id()), + } + + form_data = request.form.get("form_data") + if form_data: + try: + payload["requested_query"] = json.loads(form_data) + except json.JSONDecodeError: + pass bootstrap_data = json.dumps( payload, default=utils.pessimistic_json_iso_dttm_ser ) @@ -2764,19 +2819,6 @@ def sqllab(self): "superset/basic.html", entry="sqllab", bootstrap_data=bootstrap_data ) - @api - @handle_api_exception - @has_access_api - @expose("/slice_query/<slice_id>/") - def slice_query(self, slice_id): - """ - This method exposes an API endpoint to - get the database query string for this slice - """ - viz_obj = get_viz(slice_id) - security_manager.assert_viz_permission(viz_obj) - return self.get_query_string_response(viz_obj) - @api @has_access_api @expose("/schemas_access_for_csv_upload") @@ -2806,8 +2848,8 @@ def schemas_access_for_csv_upload(self): database, schemas_allowed, False ) return self.json_response(schemas_allowed_processed) - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) return json_error_response( "Failed to fetch schemas allowed for csv upload in this database! " "Please contact your Superset Admin!" diff --git a/superset/views/database/api.py b/superset/views/database/api.py index d4ee3c995e23..166abb7c2c11 100644 --- a/superset/views/database/api.py +++ b/superset/views/database/api.py @@ -270,9 +270,9 @@ def table_metadata(self, database: Database, table_name: str, schema_name: str): self.incr_stats("init", self.table_metadata.__name__) try: table_info: Dict = get_table_metadata(database, table_name, schema_name) - except SQLAlchemyError as e: + except SQLAlchemyError as ex: self.incr_stats("error", self.table_metadata.__name__) - return self.response_422(error_msg_from_exception(e)) + return self.response_422(error_msg_from_exception(ex)) self.incr_stats("success", self.table_metadata.__name__) return self.response(200, **table_info) diff --git a/superset/views/database/decorators.py b/superset/views/database/decorators.py index 789fbce03942..3dd0e2acd753 100644 --- a/superset/views/database/decorators.py +++ b/superset/views/database/decorators.py @@ -32,9 +32,7 @@ def check_datasource_access(f): A Decorator that checks if a user has datasource access """ - def wraps( - self, pk: int, table_name: str, schema_name: Optional[str] = None - ): # pylint: disable=invalid-name + def wraps(self, pk: int, table_name: str, schema_name: Optional[str] = None): schema_name_parsed = parse_js_uri_path_item(schema_name, eval_undefined=True) table_name_parsed = parse_js_uri_path_item(table_name) if not table_name_parsed: diff --git a/superset/views/database/mixins.py b/superset/views/database/mixins.py index 13b5bb3faa82..dae7228c89c8 100644 --- a/superset/views/database/mixins.py +++ b/superset/views/database/mixins.py @@ -65,6 +65,7 @@ class DatabaseMixin: "allow_multi_schema_metadata_fetch", "extra", "encrypted_extra", + "server_cert", ] search_exclude_columns = ( "password", @@ -74,6 +75,7 @@ class DatabaseMixin: "queries", "saved_queries", "encrypted_extra", + "server_cert", ) edit_columns = add_columns show_columns = [ @@ -149,6 +151,11 @@ class DatabaseMixin: "syntax normally used by SQLAlchemy.", True, ), + "server_cert": utils.markdown( + "Optional CA_BUNDLE contents to validate HTTPS requests. Only available " + "on certain database engines.", + True, + ), "impersonate_user": _( "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/>" @@ -183,6 +190,7 @@ class DatabaseMixin: "cache_timeout": _("Chart Cache Timeout"), "extra": _("Extra"), "encrypted_extra": _("Secure Extra"), + "server_cert": _("Root certificate"), "allow_run_async": _("Asynchronous Query Execution"), "impersonate_user": _("Impersonate the logged on user"), "allow_csv_upload": _("Allow Csv Upload"), @@ -196,6 +204,8 @@ def _pre_add_update(self, database): check_sqlalchemy_uri(database.sqlalchemy_uri) self.check_extra(database) self.check_encrypted_extra(database) + if database.server_cert: + utils.parse_ssl_cert(database.server_cert) database.set_sqlalchemy_uri(database.sqlalchemy_uri) security_manager.add_permission_view_menu("database_access", database.perm) # adding a new database we always want to force refresh schema list @@ -224,22 +234,29 @@ def check_extra(self, database): # pylint: disable=no-self-use # this will check whether json.loads(extra) can succeed try: extra = database.get_extra() - except Exception as e: - raise Exception("Extra field cannot be decoded by JSON. {}".format(str(e))) + except Exception as ex: + raise Exception( + _("Extra field cannot be decoded by JSON. %{msg}s", msg=str(ex)) + ) # this will check whether 'metadata_params' is configured correctly metadata_signature = inspect.signature(MetaData) for key in extra.get("metadata_params", {}): if key not in metadata_signature.parameters: raise Exception( - "The metadata_params in Extra field " - "is not configured correctly. The key " - "{} is invalid.".format(key) + _( + "The metadata_params in Extra field " + "is not configured correctly. The key " + "%{key}s is invalid.", + key=key, + ) ) def check_encrypted_extra(self, database): # pylint: disable=no-self-use # this will check whether json.loads(secure_extra) can succeed try: database.get_encrypted_extra() - except Exception as e: - raise Exception(f"Secure Extra field cannot be decoded as JSON. {str(e)}") + except Exception as ex: + raise Exception( + _("Extra field cannot be decoded by JSON. %{msg}s", msg=str(ex)) + ) diff --git a/superset/views/database/views.py b/superset/views/database/views.py index 94912e9868bc..47bf1c3b87d4 100644 --- a/superset/views/database/views.py +++ b/superset/views/database/views.py @@ -29,6 +29,7 @@ from superset import app, db from superset.connectors.sqla.models import SqlaTable from superset.constants import RouteMethod +from superset.exceptions import CertificateException from superset.utils import core as utils from superset.views.base import DeleteMixin, SupersetModelView, YamlExportMixin @@ -50,6 +51,17 @@ def sqlalchemy_uri_form_validator(_, field: StringField) -> None: sqlalchemy_uri_validator(field.data, exception=ValidationError) +def certificate_form_validator(_, field: StringField) -> None: + """ + Check if user has submitted a valid SSL certificate + """ + if field.data: + try: + utils.parse_ssl_cert(field.data) + except CertificateException as ex: + raise ValidationError(ex.message) + + def upload_stream_write(form_file_field: "FileStorage", path: str): chunk_size = app.config["UPLOAD_CHUNK_SIZE"] with open(path, "bw") as file_description: @@ -68,7 +80,10 @@ class DatabaseView( add_template = "superset/models/database/add.html" edit_template = "superset/models/database/edit.html" - validators_columns = {"sqlalchemy_uri": [sqlalchemy_uri_form_validator]} + validators_columns = { + "sqlalchemy_uri": [sqlalchemy_uri_form_validator], + "server_cert": [certificate_form_validator], + } yaml_dict_key = "databases" @@ -143,7 +158,7 @@ def form_post(self, form): table.fetch_metadata() db.session.add(table) db.session.commit() - except Exception as e: # pylint: disable=broad-except + except Exception as ex: # pylint: disable=broad-except db.session.rollback() try: os.remove(path) @@ -156,7 +171,7 @@ def form_post(self, form): filename=csv_filename, table_name=form.name.data, db_name=database.database_name, - error_msg=str(e), + error_msg=str(ex), ) flash(message, "danger") diff --git a/superset/views/filters.py b/superset/views/filters.py new file mode 100644 index 000000000000..3e4d85a555b5 --- /dev/null +++ b/superset/views/filters.py @@ -0,0 +1,48 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from flask_appbuilder.models.filters import BaseFilter +from flask_babel import lazy_gettext +from sqlalchemy import or_ + +from superset import security_manager + +# pylint: disable=too-few-public-methods + + +class FilterRelatedOwners(BaseFilter): + """ + A filter to allow searching for related owners of a resource. + + Use in the api by adding something like: + related_field_filters = { + "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), + } + """ + + name = lazy_gettext("Owner") + arg_name = "owners" + + def apply(self, query, value): + user_model = security_manager.user_model + like_value = "%" + value + "%" + return query.filter( + or_( + # could be made to handle spaces between names more gracefully + (user_model.first_name + " " + user_model.last_name).ilike(like_value), + user_model.username.ilike(like_value), + ) + ) diff --git a/superset/views/schedules.py b/superset/views/schedules.py index ad7861e75af5..e84e3412c8fa 100644 --- a/superset/views/schedules.py +++ b/superset/views/schedules.py @@ -48,7 +48,7 @@ class EmailScheduleView( ): # pylint: disable=too-many-ancestors include_route_methods = RouteMethod.CRUD_SET _extra_data = {"test_email": False, "test_email_recipients": None} - schedule_type: Optional[Type] = None + schedule_type: Optional[str] = None schedule_type_model: Optional[Type] = None page_size = 20 diff --git a/superset/views/utils.py b/superset/views/utils.py index 40de71e26524..edb2987ab429 100644 --- a/superset/views/utils.py +++ b/superset/views/utils.py @@ -23,7 +23,7 @@ from flask import request import superset.models.core as models -from superset import app, db, viz +from superset import app, db, is_feature_enabled from superset.connectors.connector_registry import ConnectorRegistry from superset.exceptions import SupersetException from superset.legacy import update_time_range @@ -31,6 +31,12 @@ from superset.models.slice import Slice from superset.utils.core import QueryStatus, TimeRangeEndpoint +if is_feature_enabled("SIP_38_VIZ_REARCHITECTURE"): + from superset import viz_sip38 as viz # type: ignore +else: + from superset import viz # type: ignore + + FORM_DATA_KEY_BLACKLIST: List[str] = [] if not app.config["ENABLE_JAVASCRIPT_CONTROLS"]: FORM_DATA_KEY_BLACKLIST = ["js_tooltip", "js_onclick_href", "js_data_mutator"] @@ -80,12 +86,11 @@ def get_permissions(user): def get_viz( - slice_id=None, form_data=None, datasource_type=None, datasource_id=None, force=False + form_data: Dict[str, Any], + datasource_type: str, + datasource_id: int, + force: bool = False, ): - if slice_id: - slc = db.session.query(Slice).filter_by(id=slice_id).one() - return slc.get_viz() - viz_type = form_data.get("viz_type", "table") datasource = ConnectorRegistry.get_datasource( datasource_type, datasource_id, db.session @@ -223,8 +228,9 @@ def get_time_range_endpoints( Note under certain circumstances the slice object may not exist, however the slice ID may be defined which serves as a fallback. - When SIP-15 is enabled all slices and will the [start, end) interval. If the grace - period is defined and has ended all slices will adhere to the [start, end) interval. + When SIP-15 is enabled all new slices will use the [start, end) interval. If the + grace period is defined and has ended all slices will adhere to the [start, end) + interval. :param form_data: The form-data :param slc: The slice diff --git a/superset/viz.py b/superset/viz.py index afdfa9f5a731..2101b357bfbf 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -115,6 +115,8 @@ def __init__( self.results: Optional[QueryResult] = None self.error_message: Optional[str] = None self.force = force + self.from_dttm: Optional[datetime] = None + self.to_dttm: Optional[datetime] = None # Keeping track of whether some data came from cache # this is useful to trigger the <CachedLabel /> when @@ -330,7 +332,7 @@ def query_obj(self) -> Dict[str, Any]: "druid_time_origin": form_data.get("druid_time_origin", ""), "having": form_data.get("having", ""), "having_druid": form_data.get("having_filters", []), - "time_grain_sqla": form_data.get("time_grain_sqla", ""), + "time_grain_sqla": form_data.get("time_grain_sqla"), "time_range_endpoints": form_data.get("time_range_endpoints"), "where": form_data.get("where", ""), } @@ -431,10 +433,10 @@ def get_df_payload(self, query_obj=None, **kwargs): self.status = utils.QueryStatus.SUCCESS is_loaded = True stats_logger.incr("loaded_from_cache") - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) logger.error( - "Error reading cache: " + utils.error_msg_from_exception(e) + "Error reading cache: " + utils.error_msg_from_exception(ex) ) logger.info("Serving from cache") @@ -443,11 +445,13 @@ def get_df_payload(self, query_obj=None, **kwargs): df = self.get_df(query_obj) if self.status != utils.QueryStatus.FAILED: stats_logger.incr("loaded_from_source") + if not self.force: + stats_logger.incr("loaded_from_source_without_force") is_loaded = True - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) if not self.error_message: - self.error_message = "{}".format(e) + self.error_message = "{}".format(ex) self.status = utils.QueryStatus.FAILED stacktrace = utils.get_stacktrace() @@ -467,11 +471,11 @@ def get_df_payload(self, query_obj=None, **kwargs): stats_logger.incr("set_cache_key") cache.set(cache_key, cache_value, timeout=self.cache_timeout) - except Exception as e: + except Exception as ex: # cache.set call can fail if the backend is down or if # the key is too large or whatever other reasons logger.warning("Could not cache key {}".format(cache_key)) - logger.exception(e) + logger.exception(ex) cache.delete(cache_key) return { "cache_key": self._any_cache_key, @@ -482,6 +486,8 @@ def get_df_payload(self, query_obj=None, **kwargs): "form_data": self.form_data, "is_cached": self._any_cache_key is not None, "query": self.query, + "from_dttm": self.from_dttm, + "to_dttm": self.to_dttm, "status": self.status, "stacktrace": stacktrace, "rowcount": len(df.index) if df is not None else 0, @@ -621,17 +627,18 @@ def get_data(self, df: pd.DataFrame) -> VizData: self.form_data.get("percent_metrics") or [] ) - df = pd.concat( - [ - df[non_percent_metric_columns], - ( - df[percent_metric_columns] - .div(df[percent_metric_columns].sum()) - .add_prefix("%") - ), - ], - axis=1, - ) + if not df.empty: + df = pd.concat( + [ + df[non_percent_metric_columns], + ( + df[percent_metric_columns] + .div(df[percent_metric_columns].sum()) + .add_prefix("%") + ), + ], + axis=1, + ) data = self.handle_js_int_overflow( dict(records=df.to_dict(orient="records"), columns=list(df.columns)) @@ -1673,12 +1680,15 @@ class SunburstViz(BaseViz): def get_data(self, df: pd.DataFrame) -> VizData: fd = self.form_data cols = fd.get("groupby") or [] + cols.extend(["m1", "m2"]) metric = utils.get_metric_name(fd.get("metric")) secondary_metric = utils.get_metric_name(fd.get("secondary_metric")) if metric == secondary_metric or secondary_metric is None: df.rename(columns={df.columns[-1]: "m1"}, inplace=True) df["m2"] = df["m1"] - cols.extend(["m1", "m2"]) + else: + df.rename(columns={df.columns[-2]: "m1"}, inplace=True) + df.rename(columns={df.columns[-1]: "m2"}, inplace=True) # Re-order the columns as the query result set column ordering may differ from # that listed in the hierarchy. @@ -1868,8 +1878,8 @@ def get_data(self, df: pd.DataFrame) -> VizData: for row in d: country = None if isinstance(row["country"], str): - country = countries.get(fd.get("country_fieldtype"), row["country"]) - + if "country_fieldtype" in fd: + country = countries.get(fd["country_fieldtype"], row["country"]) if country: row["country"] = country["cca3"] row["latitude"] = country["lat"] diff --git a/superset/viz_sip38.py b/superset/viz_sip38.py new file mode 100644 index 000000000000..1992bd1c14bb --- /dev/null +++ b/superset/viz_sip38.py @@ -0,0 +1,2852 @@ +# 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. +# pylint: disable=C,R,W +"""This module contains the 'Viz' objects + +These objects represent the backend of all the visualizations that +Superset can render. +""" +import copy +import hashlib +import inspect +import logging +import math +import pickle as pkl +import re +import uuid +from collections import defaultdict, OrderedDict +from datetime import datetime, timedelta +from itertools import product +from typing import Any, Dict, List, Optional, Set, Tuple, TYPE_CHECKING + +import geohash +import numpy as np +import pandas as pd +import polyline +import simplejson as json +from dateutil import relativedelta as rdelta +from flask import request +from flask_babel import lazy_gettext as _ +from geopy.point import Point +from markdown import markdown +from pandas.tseries.frequencies import to_offset + +from superset import app, cache, get_manifest_files, security_manager +from superset.constants import NULL_STRING +from superset.exceptions import NullValueException, SpatialException +from superset.models.helpers import QueryResult +from superset.typing import VizData +from superset.utils import core as utils +from superset.utils.core import ( + DTTM_ALIAS, + JS_MAX_INTEGER, + merge_extra_filters, + to_adhoc, +) + +if TYPE_CHECKING: + from superset.connectors.base.models import BaseDatasource + +config = app.config +stats_logger = config["STATS_LOGGER"] +relative_start = config["DEFAULT_RELATIVE_START_TIME"] +relative_end = config["DEFAULT_RELATIVE_END_TIME"] +logger = logging.getLogger(__name__) + +METRIC_KEYS = [ + "metric", + "metrics", + "percent_metrics", + "metric_2", + "secondary_metric", + "x", + "y", + "size", +] + +COLUMN_FORM_DATA_PARAMS = [ + "all_columns", + "all_columns_x", + "all_columns_y", + "columns", + "dimension", + "entity", + "geojson", + "groupby", + "series", + "line_column", + "js_columns", +] + +SPATIAL_COLUMN_FORM_DATA_PARAMS = ["spatial", "start_spatial", "end_spatial"] + + +class BaseViz: + + """All visualizations derive this base class""" + + viz_type: Optional[str] = None + verbose_name = "Base Viz" + credits = "" + is_timeseries = False + cache_type = "df" + enforce_numerical_metrics = True + + def __init__( + self, + datasource: "BaseDatasource", + form_data: Dict[str, Any], + force: bool = False, + ): + if not datasource: + raise Exception(_("Viz is missing a datasource")) + + self.datasource = datasource + self.request = request + self.viz_type = form_data.get("viz_type") + self.form_data = form_data + + self.query = "" + self.token = self.form_data.get("token", "token_" + uuid.uuid4().hex[:8]) + + # merge all selectable columns into `columns` property + self.columns: List[str] = [] + for key in COLUMN_FORM_DATA_PARAMS: + value = self.form_data.get(key) or [] + value_list = value if isinstance(value, list) else [value] + if value_list: + logger.warning( + f"The form field %s is deprecated. Viz plugins should " + f"pass all selectables via the columns field", + key, + ) + self.columns += value_list + + for key in SPATIAL_COLUMN_FORM_DATA_PARAMS: + spatial = self.form_data.get(key) + if not isinstance(spatial, dict): + continue + logger.warning( + f"The form field %s is deprecated. Viz plugins should " + f"pass all selectables via the columns field", + key, + ) + if spatial.get("type") == "latlong": + self.columns += [spatial["lonCol"], spatial["latCol"]] + elif spatial.get("type") == "delimited": + self.columns.append(spatial["lonlatCol"]) + elif spatial.get("type") == "geohash": + self.columns.append(spatial["geohashCol"]) + + self.time_shift = timedelta() + + self.status: Optional[str] = None + self.error_msg = "" + self.results: Optional[QueryResult] = None + self.error_message: Optional[str] = None + self.force = force + self.from_ddtm: Optional[datetime] = None + self.to_dttm: Optional[datetime] = None + + # Keeping track of whether some data came from cache + # this is useful to trigger the <CachedLabel /> when + # in the cases where visualization have many queries + # (FilterBox for instance) + self._any_cache_key: Optional[str] = None + self._any_cached_dttm: Optional[str] = None + self._extra_chart_data: List[Tuple[str, pd.DataFrame]] = [] + + self.process_metrics() + + def process_metrics(self): + # metrics in TableViz is order sensitive, so metric_dict should be + # OrderedDict + self.metric_dict = OrderedDict() + fd = self.form_data + for mkey in METRIC_KEYS: + val = fd.get(mkey) + if val: + if not isinstance(val, list): + val = [val] + for o in val: + label = utils.get_metric_name(o) + self.metric_dict[label] = o + + # Cast to list needed to return serializable object in py3 + self.all_metrics = list(self.metric_dict.values()) + self.metric_labels = list(self.metric_dict.keys()) + + @staticmethod + def handle_js_int_overflow(data): + for d in data.get("records", dict()): + for k, v in list(d.items()): + if isinstance(v, int): + # if an int is too big for Java Script to handle + # convert it to a string + if abs(v) > JS_MAX_INTEGER: + d[k] = str(v) + return data + + def run_extra_queries(self): + """Lifecycle method to use when more than one query is needed + + In rare-ish cases, a visualization may need to execute multiple + queries. That is the case for FilterBox or for time comparison + in Line chart for instance. + + In those cases, we need to make sure these queries run before the + main `get_payload` method gets called, so that the overall caching + metadata can be right. The way it works here is that if any of + the previous `get_df_payload` calls hit the cache, the main + payload's metadata will reflect that. + + The multi-query support may need more work to become a first class + use case in the framework, and for the UI to reflect the subtleties + (show that only some of the queries were served from cache for + instance). In the meantime, since multi-query is rare, we treat + it with a bit of a hack. Note that the hack became necessary + when moving from caching the visualization's data itself, to caching + the underlying query(ies). + """ + pass + + def apply_rolling(self, df): + fd = self.form_data + rolling_type = fd.get("rolling_type") + rolling_periods = int(fd.get("rolling_periods") or 0) + min_periods = int(fd.get("min_periods") or 0) + + if rolling_type in ("mean", "std", "sum") and rolling_periods: + kwargs = dict(window=rolling_periods, min_periods=min_periods) + if rolling_type == "mean": + df = df.rolling(**kwargs).mean() + elif rolling_type == "std": + df = df.rolling(**kwargs).std() + elif rolling_type == "sum": + df = df.rolling(**kwargs).sum() + elif rolling_type == "cumsum": + df = df.cumsum() + if min_periods: + df = df[min_periods:] + return df + + def get_samples(self): + query_obj = self.query_obj() + query_obj.update( + { + "metrics": [], + "row_limit": 1000, + "columns": [o.column_name for o in self.datasource.columns], + } + ) + df = self.get_df(query_obj) + return df.to_dict(orient="records") + + def get_df(self, query_obj: Optional[Dict[str, Any]] = None) -> pd.DataFrame: + """Returns a pandas dataframe based on the query object""" + if not query_obj: + query_obj = self.query_obj() + if not query_obj: + return pd.DataFrame() + + self.error_msg = "" + + timestamp_format = None + if self.datasource.type == "table": + granularity_col = self.datasource.get_column(query_obj["granularity"]) + if granularity_col: + timestamp_format = granularity_col.python_date_format + + # The datasource here can be different backend but the interface is common + self.results = self.datasource.query(query_obj) + self.query = self.results.query + self.status = self.results.status + self.error_message = self.results.error_message + + df = self.results.df + # Transform the timestamp we received from database to pandas supported + # datetime format. If no python_date_format is specified, the pattern will + # be considered as the default ISO date format + # If the datetime format is unix, the parse will use the corresponding + # parsing logic. + if not df.empty: + if DTTM_ALIAS in df.columns: + if timestamp_format in ("epoch_s", "epoch_ms"): + # Column has already been formatted as a timestamp. + dttm_col = df[DTTM_ALIAS] + one_ts_val = dttm_col[0] + + # convert time column to pandas Timestamp, but different + # ways to convert depending on string or int types + try: + int(one_ts_val) + is_integral = True + except (ValueError, TypeError): + is_integral = False + if is_integral: + unit = "s" if timestamp_format == "epoch_s" else "ms" + df[DTTM_ALIAS] = pd.to_datetime( + dttm_col, utc=False, unit=unit, origin="unix" + ) + else: + df[DTTM_ALIAS] = dttm_col.apply(pd.Timestamp) + else: + df[DTTM_ALIAS] = pd.to_datetime( + df[DTTM_ALIAS], utc=False, format=timestamp_format + ) + if self.datasource.offset: + df[DTTM_ALIAS] += timedelta(hours=self.datasource.offset) + df[DTTM_ALIAS] += self.time_shift + + if self.enforce_numerical_metrics: + self.df_metrics_to_num(df) + + df.replace([np.inf, -np.inf], np.nan, inplace=True) + return df + + def df_metrics_to_num(self, df): + """Converting metrics to numeric when pandas.read_sql cannot""" + metrics = self.metric_labels + for col, dtype in df.dtypes.items(): + if dtype.type == np.object_ and col in metrics: + df[col] = pd.to_numeric(df[col], errors="coerce") + + def process_query_filters(self): + utils.convert_legacy_filters_into_adhoc(self.form_data) + merge_extra_filters(self.form_data) + utils.split_adhoc_filters_into_base_filters(self.form_data) + + def query_obj(self) -> Dict[str, Any]: + """Building a query object""" + form_data = self.form_data + self.process_query_filters() + metrics = self.all_metrics or [] + columns = self.columns + + is_timeseries = self.is_timeseries + if DTTM_ALIAS in columns: + columns.remove(DTTM_ALIAS) + is_timeseries = True + + granularity = form_data.get("granularity") or form_data.get("granularity_sqla") + limit = int(form_data.get("limit") or 0) + timeseries_limit_metric = form_data.get("timeseries_limit_metric") + row_limit = int(form_data.get("row_limit") or config["ROW_LIMIT"]) + + # default order direction + order_desc = form_data.get("order_desc", True) + + since, until = utils.get_since_until( + relative_start=relative_start, + relative_end=relative_end, + time_range=form_data.get("time_range"), + since=form_data.get("since"), + until=form_data.get("until"), + ) + time_shift = form_data.get("time_shift", "") + self.time_shift = utils.parse_past_timedelta(time_shift) + from_dttm = None if since is None else (since - self.time_shift) + to_dttm = None if until is None else (until - self.time_shift) + if from_dttm and to_dttm and from_dttm > to_dttm: + raise Exception(_("From date cannot be larger than to date")) + + self.from_dttm = from_dttm + self.to_dttm = to_dttm + + # extras are used to query elements specific to a datasource type + # for instance the extra where clause that applies only to Tables + extras = { + "druid_time_origin": form_data.get("druid_time_origin", ""), + "having": form_data.get("having", ""), + "having_druid": form_data.get("having_filters", []), + "time_grain_sqla": form_data.get("time_grain_sqla"), + "time_range_endpoints": form_data.get("time_range_endpoints"), + "where": form_data.get("where", ""), + } + + d = { + "granularity": granularity, + "from_dttm": from_dttm, + "to_dttm": to_dttm, + "is_timeseries": is_timeseries, + "columns": columns, + "metrics": metrics, + "row_limit": row_limit, + "filter": self.form_data.get("filters", []), + "timeseries_limit": limit, + "extras": extras, + "timeseries_limit_metric": timeseries_limit_metric, + "order_desc": order_desc, + } + return d + + @property + def cache_timeout(self): + if self.form_data.get("cache_timeout") is not None: + return int(self.form_data.get("cache_timeout")) + if self.datasource.cache_timeout is not None: + return self.datasource.cache_timeout + if ( + hasattr(self.datasource, "database") + and self.datasource.database.cache_timeout + ) is not None: + return self.datasource.database.cache_timeout + return config["CACHE_DEFAULT_TIMEOUT"] + + def get_json(self): + return json.dumps( + self.get_payload(), default=utils.json_int_dttm_ser, ignore_nan=True + ) + + def cache_key(self, query_obj, **extra): + """ + The cache key is made out of the key/values in `query_obj`, plus any + other key/values in `extra`. + + We remove datetime bounds that are hard values, and replace them with + the use-provided inputs to bounds, which may be time-relative (as in + "5 days ago" or "now"). + + The `extra` arguments are currently used by time shift queries, since + different time shifts wil differ only in the `from_dttm` and `to_dttm` + values which are stripped. + """ + cache_dict = copy.copy(query_obj) + cache_dict.update(extra) + + for k in ["from_dttm", "to_dttm"]: + del cache_dict[k] + + cache_dict["time_range"] = self.form_data.get("time_range") + cache_dict["datasource"] = self.datasource.uid + cache_dict["extra_cache_keys"] = self.datasource.get_extra_cache_keys(query_obj) + cache_dict["rls"] = security_manager.get_rls_ids(self.datasource) + cache_dict["changed_on"] = self.datasource.changed_on + json_data = self.json_dumps(cache_dict, sort_keys=True) + return hashlib.md5(json_data.encode("utf-8")).hexdigest() + + def get_payload(self, query_obj=None): + """Returns a payload of metadata and data""" + self.run_extra_queries() + payload = self.get_df_payload(query_obj) + + df = payload.get("df") + if self.status != utils.QueryStatus.FAILED: + payload["data"] = self.get_data(df) + if "df" in payload: + del payload["df"] + return payload + + def get_df_payload(self, query_obj=None, **kwargs): + """Handles caching around the df payload retrieval""" + if not query_obj: + query_obj = self.query_obj() + cache_key = self.cache_key(query_obj, **kwargs) if query_obj else None + logger.info("Cache key: {}".format(cache_key)) + is_loaded = False + stacktrace = None + df = None + cached_dttm = datetime.utcnow().isoformat().split(".")[0] + if cache_key and cache and not self.force: + cache_value = cache.get(cache_key) + if cache_value: + stats_logger.incr("loading_from_cache") + try: + cache_value = pkl.loads(cache_value) + df = cache_value["df"] + self.query = cache_value["query"] + self._any_cached_dttm = cache_value["dttm"] + self._any_cache_key = cache_key + self.status = utils.QueryStatus.SUCCESS + is_loaded = True + stats_logger.incr("loaded_from_cache") + except Exception as ex: + logger.exception(ex) + logger.error( + "Error reading cache: " + utils.error_msg_from_exception(ex) + ) + logger.info("Serving from cache") + + if query_obj and not is_loaded: + try: + df = self.get_df(query_obj) + if self.status != utils.QueryStatus.FAILED: + stats_logger.incr("loaded_from_source") + if not self.force: + stats_logger.incr("loaded_from_source_without_force") + is_loaded = True + except Exception as ex: + logger.exception(ex) + if not self.error_message: + self.error_message = "{}".format(ex) + self.status = utils.QueryStatus.FAILED + stacktrace = utils.get_stacktrace() + + if ( + is_loaded + and cache_key + and cache + and self.status != utils.QueryStatus.FAILED + ): + try: + cache_value = dict(dttm=cached_dttm, df=df, query=self.query) + cache_value = pkl.dumps(cache_value, protocol=pkl.HIGHEST_PROTOCOL) + + logger.info( + "Caching {} chars at key {}".format(len(cache_value), cache_key) + ) + + stats_logger.incr("set_cache_key") + cache.set(cache_key, cache_value, timeout=self.cache_timeout) + except Exception as ex: + # cache.set call can fail if the backend is down or if + # the key is too large or whatever other reasons + logger.warning("Could not cache key {}".format(cache_key)) + logger.exception(ex) + cache.delete(cache_key) + + return { + "cache_key": self._any_cache_key, + "cached_dttm": self._any_cached_dttm, + "cache_timeout": self.cache_timeout, + "df": df, + "error": self.error_message, + "form_data": self.form_data, + "is_cached": self._any_cache_key is not None, + "query": self.query, + "from_dttm": self.from_dttm, + "to_dttm": self.to_dttm, + "status": self.status, + "stacktrace": stacktrace, + "rowcount": len(df.index) if df is not None else 0, + } + + def json_dumps(self, obj, sort_keys=False): + return json.dumps( + obj, default=utils.json_int_dttm_ser, ignore_nan=True, sort_keys=sort_keys + ) + + def payload_json_and_has_error(self, payload): + has_error = ( + payload.get("status") == utils.QueryStatus.FAILED + or payload.get("error") is not None + ) + return self.json_dumps(payload), has_error + + @property + def data(self): + """This is the data object serialized to the js layer""" + content = { + "form_data": self.form_data, + "token": self.token, + "viz_name": self.viz_type, + "filter_select_enabled": self.datasource.filter_select_enabled, + } + return content + + def get_csv(self): + df = self.get_df() + include_index = not isinstance(df.index, pd.RangeIndex) + return df.to_csv(index=include_index, **config["CSV_EXPORT"]) + + def get_data(self, df: pd.DataFrame) -> VizData: + return df.to_dict(orient="records") + + @property + def json_data(self): + return json.dumps(self.data) + + +class TableViz(BaseViz): + + """A basic html table that is sortable and searchable""" + + viz_type = "table" + verbose_name = _("Table View") + credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original' + is_timeseries = False + enforce_numerical_metrics = False + + def should_be_timeseries(self): + fd = self.form_data + # TODO handle datasource-type-specific code in datasource + conditions_met = (fd.get("granularity") and fd.get("granularity") != "all") or ( + fd.get("granularity_sqla") and fd.get("time_grain_sqla") + ) + if fd.get("include_time") and not conditions_met: + raise Exception( + _("Pick a granularity in the Time section or " "uncheck 'Include Time'") + ) + return fd.get("include_time") + + def query_obj(self): + d = super().query_obj() + fd = self.form_data + + if fd.get("all_columns") and ( + fd.get("groupby") or fd.get("metrics") or fd.get("percent_metrics") + ): + raise Exception( + _( + "Choose either fields to [Group By] and [Metrics] and/or " + "[Percentage Metrics], or [Columns], not both" + ) + ) + + sort_by = fd.get("timeseries_limit_metric") + if fd.get("all_columns"): + order_by_cols = fd.get("order_by_cols") or [] + d["orderby"] = [json.loads(t) for t in order_by_cols] + elif sort_by: + sort_by_label = utils.get_metric_name(sort_by) + if sort_by_label not in utils.get_metric_names(d["metrics"]): + d["metrics"] += [sort_by] + d["orderby"] = [(sort_by, not fd.get("order_desc", True))] + + # Add all percent metrics that are not already in the list + if "percent_metrics" in fd: + d["metrics"].extend( + m for m in fd["percent_metrics"] or [] if m not in d["metrics"] + ) + + d["is_timeseries"] = self.should_be_timeseries() + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + """ + Transform the query result to the table representation. + + :param df: The interim dataframe + :returns: The table visualization data + + The interim dataframe comprises of the group-by and non-group-by columns and + the union of the metrics representing the non-percent and percent metrics. Note + the percent metrics have yet to be transformed. + """ + + non_percent_metric_columns = [] + # Transform the data frame to adhere to the UI ordering of the columns and + # metrics whilst simultaneously computing the percentages (via normalization) + # for the percent metrics. + + if DTTM_ALIAS in df: + if self.should_be_timeseries(): + non_percent_metric_columns.append(DTTM_ALIAS) + else: + del df[DTTM_ALIAS] + + non_percent_metric_columns.extend( + self.form_data.get("all_columns") or self.form_data.get("groupby") or [] + ) + + non_percent_metric_columns.extend( + utils.get_metric_names(self.form_data.get("metrics") or []) + ) + + timeseries_limit_metric = utils.get_metric_name( + self.form_data.get("timeseries_limit_metric") + ) + if timeseries_limit_metric: + non_percent_metric_columns.append(timeseries_limit_metric) + + percent_metric_columns = utils.get_metric_names( + self.form_data.get("percent_metrics") or [] + ) + + if not df.empty: + df = pd.concat( + [ + df[non_percent_metric_columns], + ( + df[percent_metric_columns] + .div(df[percent_metric_columns].sum()) + .add_prefix("%") + ), + ], + axis=1, + ) + + data = self.handle_js_int_overflow( + dict(records=df.to_dict(orient="records"), columns=list(df.columns)) + ) + + return data + + def json_dumps(self, obj, sort_keys=False): + return json.dumps( + obj, default=utils.json_iso_dttm_ser, sort_keys=sort_keys, ignore_nan=True + ) + + +class TimeTableViz(BaseViz): + + """A data table with rich time-series related columns""" + + viz_type = "time_table" + verbose_name = _("Time Table View") + credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original' + is_timeseries = True + + def query_obj(self): + d = super().query_obj() + fd = self.form_data + + if not fd.get("metrics"): + raise Exception(_("Pick at least one metric")) + + if fd.get("groupby") and len(fd.get("metrics")) > 1: + raise Exception( + _("When using 'Group By' you are limited to use a single metric") + ) + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + fd = self.form_data + columns = None + values = self.metric_labels + if fd.get("groupby"): + values = self.metric_labels[0] + columns = fd.get("groupby") + pt = df.pivot_table(index=DTTM_ALIAS, columns=columns, values=values) + pt.index = pt.index.map(str) + pt = pt.sort_index() + return dict( + records=pt.to_dict(orient="index"), + columns=list(pt.columns), + is_group_by=len(fd.get("groupby", [])) > 0, + ) + + +class PivotTableViz(BaseViz): + + """A pivot table view, define your rows, columns and metrics""" + + viz_type = "pivot_table" + verbose_name = _("Pivot Table") + credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original' + is_timeseries = False + + def query_obj(self): + d = super().query_obj() + groupby = self.form_data.get("groupby") + columns = self.form_data.get("columns") + metrics = self.form_data.get("metrics") + transpose = self.form_data.get("transpose_pivot") + if not columns: + columns = [] + if not groupby: + groupby = [] + if not groupby: + raise Exception(_("Please choose at least one 'Group by' field ")) + if transpose and not columns: + raise Exception( + _( + ( + "Please choose at least one 'Columns' field when " + "select 'Transpose Pivot' option" + ) + ) + ) + if not metrics: + raise Exception(_("Please choose at least one metric")) + if set(groupby) & set(columns): + raise Exception(_("Group By' and 'Columns' can't overlap")) + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + if self.form_data.get("granularity") == "all" and DTTM_ALIAS in df: + del df[DTTM_ALIAS] + + aggfunc = self.form_data.get("pandas_aggfunc") or "sum" + + # Ensure that Pandas's sum function mimics that of SQL. + if aggfunc == "sum": + aggfunc = lambda x: x.sum(min_count=1) + + groupby = self.form_data.get("groupby") + columns = self.form_data.get("columns") + if self.form_data.get("transpose_pivot"): + groupby, columns = columns, groupby + metrics = [utils.get_metric_name(m) for m in self.form_data["metrics"]] + df = df.pivot_table( + index=groupby, + columns=columns, + values=metrics, + aggfunc=aggfunc, + margins=self.form_data.get("pivot_margins"), + ) + + # Re-order the columns adhering to the metric ordering. + df = df[metrics] + + # Display metrics side by side with each column + if self.form_data.get("combine_metric"): + df = df.stack(0).unstack() + return dict( + columns=list(df.columns), + html=df.to_html( + na_rep="null", + classes=( + "dataframe table table-striped table-bordered " + "table-condensed table-hover" + ).split(" "), + ), + ) + + +class MarkupViz(BaseViz): + + """Use html or markdown to create a free form widget""" + + viz_type = "markup" + verbose_name = _("Markup") + is_timeseries = False + + def query_obj(self): + return None + + def get_df(self, query_obj: Optional[Dict[str, Any]] = None) -> pd.DataFrame: + return pd.DataFrame() + + def get_data(self, df: pd.DataFrame) -> VizData: + markup_type = self.form_data.get("markup_type") + code = self.form_data.get("code", "") + if markup_type == "markdown": + code = markdown(code) + return dict(html=code, theme_css=get_manifest_files("theme", "css")) + + +class SeparatorViz(MarkupViz): + + """Use to create section headers in a dashboard, similar to `Markup`""" + + viz_type = "separator" + verbose_name = _("Separator") + + +class WordCloudViz(BaseViz): + + """Build a colorful word cloud + + Uses the nice library at: + https://github.com/jasondavies/d3-cloud + """ + + viz_type = "word_cloud" + verbose_name = _("Word Cloud") + is_timeseries = False + + +class TreemapViz(BaseViz): + + """Tree map visualisation for hierarchical data.""" + + viz_type = "treemap" + verbose_name = _("Treemap") + credits = '<a href="https://d3js.org">d3.js</a>' + is_timeseries = False + + def _nest(self, metric, df): + nlevels = df.index.nlevels + if nlevels == 1: + result = [{"name": n, "value": v} for n, v in zip(df.index, df[metric])] + else: + result = [ + {"name": l, "children": self._nest(metric, df.loc[l])} + for l in df.index.levels[0] + ] + return result + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + df = df.set_index(self.form_data.get("groupby")) + chart_data = [ + {"name": metric, "children": self._nest(metric, df)} + for metric in df.columns + ] + return chart_data + + +class CalHeatmapViz(BaseViz): + + """Calendar heatmap.""" + + viz_type = "cal_heatmap" + verbose_name = _("Calendar Heatmap") + credits = "<a href=https://github.com/wa0x6e/cal-heatmap>cal-heatmap</a>" + is_timeseries = True + + def get_data(self, df: pd.DataFrame) -> VizData: + form_data = self.form_data + + data = {} + records = df.to_dict("records") + for metric in self.metric_labels: + values = {} + for obj in records: + v = obj[DTTM_ALIAS] + if hasattr(v, "value"): + v = v.value + values[str(v / 10 ** 9)] = obj.get(metric) + data[metric] = values + + start, end = utils.get_since_until( + relative_start=relative_start, + relative_end=relative_end, + time_range=form_data.get("time_range"), + since=form_data.get("since"), + until=form_data.get("until"), + ) + if not start or not end: + raise Exception("Please provide both time bounds (Since and Until)") + domain = form_data.get("domain_granularity") + diff_delta = rdelta.relativedelta(end, start) + diff_secs = (end - start).total_seconds() + + if domain == "year": + range_ = diff_delta.years + 1 + elif domain == "month": + range_ = diff_delta.years * 12 + diff_delta.months + 1 + elif domain == "week": + range_ = diff_delta.years * 53 + diff_delta.weeks + 1 + elif domain == "day": + range_ = diff_secs // (24 * 60 * 60) + 1 # type: ignore + else: + range_ = diff_secs // (60 * 60) + 1 # type: ignore + + return { + "data": data, + "start": start, + "domain": domain, + "subdomain": form_data.get("subdomain_granularity"), + "range": range_, + } + + def query_obj(self): + d = super().query_obj() + fd = self.form_data + d["metrics"] = fd.get("metrics") + return d + + +class NVD3Viz(BaseViz): + + """Base class for all nvd3 vizs""" + + credits = '<a href="http://nvd3.org/">NVD3.org</a>' + viz_type: Optional[str] = None + verbose_name = "Base NVD3 Viz" + is_timeseries = False + + +class BoxPlotViz(NVD3Viz): + + """Box plot viz from ND3""" + + viz_type = "box_plot" + verbose_name = _("Box Plot") + sort_series = False + is_timeseries = True + + def to_series(self, df, classed="", title_suffix=""): + label_sep = " - " + chart_data = [] + for index_value, row in zip(df.index, df.to_dict(orient="records")): + if isinstance(index_value, tuple): + index_value = label_sep.join(index_value) + boxes = defaultdict(dict) + for (label, key), value in row.items(): + if key == "nanmedian": + key = "Q2" + boxes[label][key] = value + for label, box in boxes.items(): + if len(self.form_data.get("metrics")) > 1: + # need to render data labels with metrics + chart_label = label_sep.join([index_value, label]) + else: + chart_label = index_value + chart_data.append({"label": chart_label, "values": box}) + return chart_data + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + form_data = self.form_data + + # conform to NVD3 names + def Q1(series): # need to be named functions - can't use lambdas + return np.nanpercentile(series, 25) + + def Q3(series): + return np.nanpercentile(series, 75) + + whisker_type = form_data.get("whisker_options") + if whisker_type == "Tukey": + + def whisker_high(series): + upper_outer_lim = Q3(series) + 1.5 * (Q3(series) - Q1(series)) + return series[series <= upper_outer_lim].max() + + def whisker_low(series): + lower_outer_lim = Q1(series) - 1.5 * (Q3(series) - Q1(series)) + return series[series >= lower_outer_lim].min() + + elif whisker_type == "Min/max (no outliers)": + + def whisker_high(series): + return series.max() + + def whisker_low(series): + return series.min() + + elif " percentiles" in whisker_type: # type: ignore + low, high = whisker_type.replace(" percentiles", "").split( # type: ignore + "/" + ) + + def whisker_high(series): + return np.nanpercentile(series, int(high)) + + def whisker_low(series): + return np.nanpercentile(series, int(low)) + + else: + raise ValueError("Unknown whisker type: {}".format(whisker_type)) + + def outliers(series): + above = series[series > whisker_high(series)] + below = series[series < whisker_low(series)] + # pandas sometimes doesn't like getting lists back here + return set(above.tolist() + below.tolist()) + + aggregate = [Q1, np.nanmedian, Q3, whisker_high, whisker_low, outliers] + df = df.groupby(form_data.get("groupby")).agg(aggregate) + chart_data = self.to_series(df) + return chart_data + + +class BubbleViz(NVD3Viz): + + """Based on the NVD3 bubble chart""" + + viz_type = "bubble" + verbose_name = _("Bubble Chart") + is_timeseries = False + + def query_obj(self): + form_data = self.form_data + d = super().query_obj() + + self.x_metric = form_data.get("x") + self.y_metric = form_data.get("y") + self.z_metric = form_data.get("size") + self.entity = form_data.get("entity") + self.series = form_data.get("series") or self.entity + d["row_limit"] = form_data.get("limit") + + d["metrics"] = [self.z_metric, self.x_metric, self.y_metric] + if len(set(self.metric_labels)) < 3: + raise Exception(_("Please use 3 different metric labels")) + if not all(d["metrics"] + [self.entity]): + raise Exception(_("Pick a metric for x, y and size")) + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + df["x"] = df[[utils.get_metric_name(self.x_metric)]] + df["y"] = df[[utils.get_metric_name(self.y_metric)]] + df["size"] = df[[utils.get_metric_name(self.z_metric)]] + df["shape"] = "circle" + df["group"] = df[[self.series]] + + series: Dict[Any, List[Any]] = defaultdict(list) + for row in df.to_dict(orient="records"): + series[row["group"]].append(row) + chart_data = [] + for k, v in series.items(): + chart_data.append({"key": k, "values": v}) + return chart_data + + +class BulletViz(NVD3Viz): + + """Based on the NVD3 bullet chart""" + + viz_type = "bullet" + verbose_name = _("Bullet Chart") + is_timeseries = False + + def query_obj(self): + form_data = self.form_data + d = super().query_obj() + self.metric = form_data.get("metric") + + def as_strings(field): + value = form_data.get(field) + return value.split(",") if value else [] + + def as_floats(field): + return [float(x) for x in as_strings(field)] + + self.ranges = as_floats("ranges") + self.range_labels = as_strings("range_labels") + self.markers = as_floats("markers") + self.marker_labels = as_strings("marker_labels") + self.marker_lines = as_floats("marker_lines") + self.marker_line_labels = as_strings("marker_line_labels") + + d["metrics"] = [self.metric] + if not self.metric: + raise Exception(_("Pick a metric to display")) + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + df["metric"] = df[[utils.get_metric_name(self.metric)]] + values = df["metric"].values + return { + "measures": values.tolist(), + "ranges": self.ranges or [0, values.max() * 1.1], + "rangeLabels": self.range_labels or None, + "markers": self.markers or None, + "markerLabels": self.marker_labels or None, + "markerLines": self.marker_lines or None, + "markerLineLabels": self.marker_line_labels or None, + } + + +class BigNumberViz(BaseViz): + + """Put emphasis on a single metric with this big number viz""" + + viz_type = "big_number" + verbose_name = _("Big Number with Trendline") + credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original' + is_timeseries = True + + def query_obj(self): + d = super().query_obj() + metric = self.form_data.get("metric") + if not metric: + raise Exception(_("Pick a metric!")) + d["metrics"] = [self.form_data.get("metric")] + self.form_data["metric"] = metric + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + df = df.pivot_table( + index=DTTM_ALIAS, + columns=[], + values=self.metric_labels, + dropna=False, + aggfunc=np.min, # looking for any (only) value, preserving `None` + ) + df = self.apply_rolling(df) + df[DTTM_ALIAS] = df.index + return super().get_data(df) + + +class BigNumberTotalViz(BaseViz): + + """Put emphasis on a single metric with this big number viz""" + + viz_type = "big_number_total" + verbose_name = _("Big Number") + credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original' + is_timeseries = False + + def query_obj(self): + d = super().query_obj() + metric = self.form_data.get("metric") + if not metric: + raise Exception(_("Pick a metric!")) + d["metrics"] = [self.form_data.get("metric")] + self.form_data["metric"] = metric + + # Limiting rows is not required as only one cell is returned + d["row_limit"] = None + return d + + +class NVD3TimeSeriesViz(NVD3Viz): + + """A rich line chart component with tons of options""" + + viz_type = "line" + verbose_name = _("Time Series - Line Chart") + sort_series = False + is_timeseries = True + pivot_fill_value: Optional[int] = None + + def to_series(self, df, classed="", title_suffix=""): + cols = [] + for col in df.columns: + if col == "": + cols.append("N/A") + elif col is None: + cols.append("NULL") + else: + cols.append(col) + df.columns = cols + series = df.to_dict("series") + + chart_data = [] + for name in df.T.index.tolist(): + ys = series[name] + if df[name].dtype.kind not in "biufc": + continue + if isinstance(name, list): + series_title = [str(title) for title in name] + elif isinstance(name, tuple): + series_title = tuple(str(title) for title in name) + else: + series_title = str(name) + if ( + isinstance(series_title, (list, tuple)) + and len(series_title) > 1 + and len(self.metric_labels) == 1 + ): + # Removing metric from series name if only one metric + series_title = series_title[1:] + if title_suffix: + if isinstance(series_title, str): + series_title = (series_title, title_suffix) + elif isinstance(series_title, (list, tuple)): + series_title = series_title + (title_suffix,) + + values = [] + non_nan_cnt = 0 + for ds in df.index: + if ds in ys: + d = {"x": ds, "y": ys[ds]} + if not np.isnan(ys[ds]): + non_nan_cnt += 1 + else: + d = {} + values.append(d) + + if non_nan_cnt == 0: + continue + + d = {"key": series_title, "values": values} + if classed: + d["classed"] = classed + chart_data.append(d) + return chart_data + + def process_data(self, df: pd.DataFrame, aggregate: bool = False) -> VizData: + fd = self.form_data + if fd.get("granularity") == "all": + raise Exception(_("Pick a time granularity for your time series")) + + if df.empty: + return df + + if aggregate: + df = df.pivot_table( + index=DTTM_ALIAS, + columns=self.columns, + values=self.metric_labels, + fill_value=0, + aggfunc=sum, + ) + else: + df = df.pivot_table( + index=DTTM_ALIAS, + columns=self.columns, + values=self.metric_labels, + fill_value=self.pivot_fill_value, + ) + + rule = fd.get("resample_rule") + method = fd.get("resample_method") + + if rule and method: + df = getattr(df.resample(rule), method)() + + if self.sort_series: + dfs = df.sum() + dfs.sort_values(ascending=False, inplace=True) + df = df[dfs.index] + + df = self.apply_rolling(df) + if fd.get("contribution"): + dft = df.T + df = (dft / dft.sum()).T + + return df + + def run_extra_queries(self): + fd = self.form_data + + time_compare = fd.get("time_compare") or [] + # backwards compatibility + if not isinstance(time_compare, list): + time_compare = [time_compare] + + for option in time_compare: + query_object = self.query_obj() + delta = utils.parse_past_timedelta(option) + query_object["inner_from_dttm"] = query_object["from_dttm"] + query_object["inner_to_dttm"] = query_object["to_dttm"] + + if not query_object["from_dttm"] or not query_object["to_dttm"]: + raise Exception( + _( + "`Since` and `Until` time bounds should be specified " + "when using the `Time Shift` feature." + ) + ) + query_object["from_dttm"] -= delta + query_object["to_dttm"] -= delta + + df2 = self.get_df_payload(query_object, time_compare=option).get("df") + if df2 is not None and DTTM_ALIAS in df2: + label = "{} offset".format(option) + df2[DTTM_ALIAS] += delta + df2 = self.process_data(df2) + self._extra_chart_data.append((label, df2)) + + def get_data(self, df: pd.DataFrame) -> VizData: + fd = self.form_data + comparison_type = fd.get("comparison_type") or "values" + df = self.process_data(df) + if comparison_type == "values": + # Filter out series with all NaN + chart_data = self.to_series(df.dropna(axis=1, how="all")) + + for i, (label, df2) in enumerate(self._extra_chart_data): + chart_data.extend( + self.to_series( + df2, classed="time-shift-{}".format(i), title_suffix=label + ) + ) + else: + chart_data = [] + for i, (label, df2) in enumerate(self._extra_chart_data): + # reindex df2 into the df2 index + combined_index = df.index.union(df2.index) + df2 = ( + df2.reindex(combined_index) + .interpolate(method="time") + .reindex(df.index) + ) + + if comparison_type == "absolute": + diff = df - df2 + elif comparison_type == "percentage": + diff = (df - df2) / df2 + elif comparison_type == "ratio": + diff = df / df2 + else: + raise Exception( + "Invalid `comparison_type`: {0}".format(comparison_type) + ) + + # remove leading/trailing NaNs from the time shift difference + diff = diff[diff.first_valid_index() : diff.last_valid_index()] + + chart_data.extend( + self.to_series( + diff, classed="time-shift-{}".format(i), title_suffix=label + ) + ) + + if not self.sort_series: + chart_data = sorted(chart_data, key=lambda x: tuple(x["key"])) + return chart_data + + +class MultiLineViz(NVD3Viz): + + """Pile on multiple line charts""" + + viz_type = "line_multi" + verbose_name = _("Time Series - Multiple Line Charts") + + is_timeseries = True + + def query_obj(self): + return None + + def get_data(self, df: pd.DataFrame) -> VizData: + fd = self.form_data + # Late imports to avoid circular import issues + from superset.models.slice import Slice + from superset import db + + slice_ids1 = fd.get("line_charts") + slices1 = db.session.query(Slice).filter(Slice.id.in_(slice_ids1)).all() + slice_ids2 = fd.get("line_charts_2") + slices2 = db.session.query(Slice).filter(Slice.id.in_(slice_ids2)).all() + return { + "slices": { + "axis1": [slc.data for slc in slices1], + "axis2": [slc.data for slc in slices2], + } + } + + +class NVD3DualLineViz(NVD3Viz): + + """A rich line chart with dual axis""" + + viz_type = "dual_line" + verbose_name = _("Time Series - Dual Axis Line Chart") + sort_series = False + is_timeseries = True + + def query_obj(self): + d = super().query_obj() + m1 = self.form_data.get("metric") + m2 = self.form_data.get("metric_2") + d["metrics"] = [m1, m2] + if not m1: + raise Exception(_("Pick a metric for left axis!")) + if not m2: + raise Exception(_("Pick a metric for right axis!")) + if m1 == m2: + raise Exception( + _("Please choose different metrics" " on left and right axis") + ) + return d + + def to_series(self, df, classed=""): + cols = [] + for col in df.columns: + if col == "": + cols.append("N/A") + elif col is None: + cols.append("NULL") + else: + cols.append(col) + df.columns = cols + series = df.to_dict("series") + chart_data = [] + metrics = [self.form_data.get("metric"), self.form_data.get("metric_2")] + for i, m in enumerate(metrics): + m = utils.get_metric_name(m) + ys = series[m] + if df[m].dtype.kind not in "biufc": + continue + series_title = m + d = { + "key": series_title, + "classed": classed, + "values": [ + {"x": ds, "y": ys[ds] if ds in ys else None} for ds in df.index + ], + "yAxis": i + 1, + "type": "line", + } + chart_data.append(d) + return chart_data + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + fd = self.form_data + + if self.form_data.get("granularity") == "all": + raise Exception(_("Pick a time granularity for your time series")) + + metric = utils.get_metric_name(fd.get("metric")) + metric_2 = utils.get_metric_name(fd.get("metric_2")) + df = df.pivot_table(index=DTTM_ALIAS, values=[metric, metric_2]) + + chart_data = self.to_series(df) + return chart_data + + +class NVD3TimeSeriesBarViz(NVD3TimeSeriesViz): + + """A bar chart where the x axis is time""" + + viz_type = "bar" + sort_series = True + verbose_name = _("Time Series - Bar Chart") + + +class NVD3TimePivotViz(NVD3TimeSeriesViz): + + """Time Series - Periodicity Pivot""" + + viz_type = "time_pivot" + sort_series = True + verbose_name = _("Time Series - Period Pivot") + + def query_obj(self): + d = super().query_obj() + d["metrics"] = [self.form_data.get("metric")] + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + fd = self.form_data + df = self.process_data(df) + freq = to_offset(fd.get("freq")) + try: + freq = type(freq)(freq.n, normalize=True, **freq.kwds) + except ValueError: + freq = type(freq)(freq.n, **freq.kwds) + df.index.name = None + df[DTTM_ALIAS] = df.index.map(freq.rollback) + df["ranked"] = df[DTTM_ALIAS].rank(method="dense", ascending=False) - 1 + df.ranked = df.ranked.map(int) + df["series"] = "-" + df.ranked.map(str) + df["series"] = df["series"].str.replace("-0", "current") + rank_lookup = { + row["series"]: row["ranked"] for row in df.to_dict(orient="records") + } + max_ts = df[DTTM_ALIAS].max() + max_rank = df["ranked"].max() + df[DTTM_ALIAS] = df.index + (max_ts - df[DTTM_ALIAS]) + df = df.pivot_table( + index=DTTM_ALIAS, + columns="series", + values=utils.get_metric_name(fd.get("metric")), + ) + chart_data = self.to_series(df) + for serie in chart_data: + serie["rank"] = rank_lookup[serie["key"]] + serie["perc"] = 1 - (serie["rank"] / (max_rank + 1)) + return chart_data + + +class NVD3CompareTimeSeriesViz(NVD3TimeSeriesViz): + + """A line chart component where you can compare the % change over time""" + + viz_type = "compare" + verbose_name = _("Time Series - Percent Change") + + +class NVD3TimeSeriesStackedViz(NVD3TimeSeriesViz): + + """A rich stack area chart""" + + viz_type = "area" + verbose_name = _("Time Series - Stacked") + sort_series = True + pivot_fill_value = 0 + + +class DistributionPieViz(NVD3Viz): + + """Annoy visualization snobs with this controversial pie chart""" + + viz_type = "pie" + verbose_name = _("Distribution - NVD3 - Pie Chart") + is_timeseries = False + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + metric = self.metric_labels[0] + df = df.pivot_table(index=self.columns, values=[metric]) + df.sort_values(by=metric, ascending=False, inplace=True) + df = df.reset_index() + df.columns = ["x", "y"] + return df.to_dict(orient="records") + + +class HistogramViz(BaseViz): + + """Histogram""" + + viz_type = "histogram" + verbose_name = _("Histogram") + is_timeseries = False + + def query_obj(self): + """Returns the query object for this visualization""" + d = super().query_obj() + d["row_limit"] = self.form_data.get("row_limit", int(config["VIZ_ROW_LIMIT"])) + if not self.form_data.get("all_columns_x"): + raise Exception(_("Must have at least one numeric column specified")) + return d + + def labelify(self, keys, column): + if isinstance(keys, str): + keys = (keys,) + # removing undesirable characters + labels = [re.sub(r"\W+", r"_", k) for k in keys] + if len(self.columns) > 1: + # Only show numeric column in label if there are many + labels = [column] + labels + return "__".join(labels) + + def get_data(self, df: pd.DataFrame) -> VizData: + """Returns the chart data""" + groupby = self.form_data.get("groupby") + + if df.empty: + return None + + chart_data = [] + if groupby: + groups = df.groupby(groupby) + else: + groups = [((), df)] + for keys, data in groups: + chart_data.extend( + [ + { + "key": self.labelify(keys, column), + "values": data[column].tolist(), + } + for column in self.columns + ] + ) + return chart_data + + +class DistributionBarViz(DistributionPieViz): + + """A good old bar chart""" + + viz_type = "dist_bar" + verbose_name = _("Distribution - Bar Chart") + is_timeseries = False + + def query_obj(self): + # TODO: Refactor this plugin to either perform grouping or assume + # preaggretagion of metrics ("numeric columns") + d = super().query_obj() + fd = self.form_data + if not self.all_metrics: + raise Exception(_("Pick at least one metric")) + if not self.columns: + raise Exception(_("Pick at least one field for [Series]")) + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + fd = self.form_data + metrics = self.metric_labels + # TODO: will require post transformation logic not currently available in + # /api/v1/query endpoint + columns = fd.get("columns") or [] + groupby = fd.get("groupby") or [] + + # pandas will throw away nulls when grouping/pivoting, + # so we substitute NULL_STRING for any nulls in the necessary columns + df[self.columns] = df[self.columns].fillna(value=NULL_STRING) + + row = df.groupby(groupby).sum()[metrics[0]].copy() + row.sort_values(ascending=False, inplace=True) + pt = df.pivot_table(index=groupby, columns=columns, values=metrics) + if fd.get("contribution"): + pt = pt.T + pt = (pt / pt.sum()).T + pt = pt.reindex(row.index) + chart_data = [] + for name, ys in pt.items(): + if pt[name].dtype.kind not in "biufc" or name in groupby: + continue + if isinstance(name, str): + series_title = name + else: + offset = 0 if len(metrics) > 1 else 1 + series_title = ", ".join([str(s) for s in name[offset:]]) + values = [] + for i, v in ys.items(): + x = i + if isinstance(x, (tuple, list)): + x = ", ".join([str(s) for s in x]) + else: + x = str(x) + values.append({"x": x, "y": v}) + d = {"key": series_title, "values": values} + chart_data.append(d) + return chart_data + + +class SunburstViz(BaseViz): + + """A multi level sunburst chart""" + + viz_type = "sunburst" + verbose_name = _("Sunburst") + is_timeseries = False + credits = ( + "Kerry Rodden " + '@<a href="https://bl.ocks.org/kerryrodden/7090426">bl.ocks.org</a>' + ) + + def get_data(self, df: pd.DataFrame) -> VizData: + fd = self.form_data + cols = fd.get("groupby") or [] + cols.extend(["m1", "m2"]) + metric = utils.get_metric_name(fd.get("metric")) + secondary_metric = utils.get_metric_name(fd.get("secondary_metric")) + if metric == secondary_metric or secondary_metric is None: + df.rename(columns={df.columns[-1]: "m1"}, inplace=True) + df["m2"] = df["m1"] + else: + df.rename(columns={df.columns[-2]: "m1"}, inplace=True) + df.rename(columns={df.columns[-1]: "m2"}, inplace=True) + + # Re-order the columns as the query result set column ordering may differ from + # that listed in the hierarchy. + df = df[cols] + return df.to_numpy().tolist() + + def query_obj(self): + qry = super().query_obj() + fd = self.form_data + qry["metrics"] = [fd["metric"]] + secondary_metric = fd.get("secondary_metric") + if secondary_metric and secondary_metric != fd["metric"]: + qry["metrics"].append(secondary_metric) + return qry + + +class SankeyViz(BaseViz): + + """A Sankey diagram that requires a parent-child dataset""" + + viz_type = "sankey" + verbose_name = _("Sankey") + is_timeseries = False + credits = '<a href="https://www.npmjs.com/package/d3-sankey">d3-sankey on npm</a>' + + def get_data(self, df: pd.DataFrame) -> VizData: + df.columns = ["source", "target", "value"] + df["source"] = df["source"].astype(str) + df["target"] = df["target"].astype(str) + recs = df.to_dict(orient="records") + + hierarchy: Dict[str, Set[str]] = defaultdict(set) + for row in recs: + hierarchy[row["source"]].add(row["target"]) + + def find_cycle(g): + """Whether there's a cycle in a directed graph""" + path = set() + + def visit(vertex): + path.add(vertex) + for neighbour in g.get(vertex, ()): + if neighbour in path or visit(neighbour): + return (vertex, neighbour) + path.remove(vertex) + + for v in g: + cycle = visit(v) + if cycle: + return cycle + + cycle = find_cycle(hierarchy) + if cycle: + raise Exception( + _( + "There's a loop in your Sankey, please provide a tree. " + "Here's a faulty link: {}" + ).format(cycle) + ) + return recs + + +class DirectedForceViz(BaseViz): + + """An animated directed force layout graph visualization""" + + viz_type = "directed_force" + verbose_name = _("Directed Force Layout") + credits = 'd3noob @<a href="http://bl.ocks.org/d3noob/5141278">bl.ocks.org</a>' + is_timeseries = False + + def query_obj(self): + qry = super().query_obj() + if len(self.form_data["groupby"]) != 2: + raise Exception(_("Pick exactly 2 columns to 'Group By'")) + qry["metrics"] = [self.form_data["metric"]] + return qry + + def get_data(self, df: pd.DataFrame) -> VizData: + df.columns = ["source", "target", "value"] + return df.to_dict(orient="records") + + +class ChordViz(BaseViz): + + """A Chord diagram""" + + viz_type = "chord" + verbose_name = _("Directed Force Layout") + credits = '<a href="https://github.com/d3/d3-chord">Bostock</a>' + is_timeseries = False + + def query_obj(self): + qry = super().query_obj() + fd = self.form_data + qry["metrics"] = [fd.get("metric")] + return qry + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + df.columns = ["source", "target", "value"] + + # Preparing a symetrical matrix like d3.chords calls for + nodes = list(set(df["source"]) | set(df["target"])) + matrix = {} + for source, target in product(nodes, nodes): + matrix[(source, target)] = 0 + for source, target, value in df.to_records(index=False): + matrix[(source, target)] = value + m = [[matrix[(n1, n2)] for n1 in nodes] for n2 in nodes] + return {"nodes": list(nodes), "matrix": m} + + +class CountryMapViz(BaseViz): + + """A country centric""" + + viz_type = "country_map" + verbose_name = _("Country Map") + is_timeseries = False + credits = "From bl.ocks.org By john-guerra" + + def get_data(self, df: pd.DataFrame) -> VizData: + fd = self.form_data + cols = [fd.get("entity")] + metric = self.metric_labels[0] + cols += [metric] + ndf = df[cols] + df = ndf + df.columns = ["country_id", "metric"] + d = df.to_dict(orient="records") + return d + + +class WorldMapViz(BaseViz): + + """A country centric world map""" + + viz_type = "world_map" + verbose_name = _("World Map") + is_timeseries = False + credits = 'datamaps on <a href="https://www.npmjs.com/package/datamaps">npm</a>' + + def get_data(self, df: pd.DataFrame) -> VizData: + from superset.examples import countries + + fd = self.form_data + cols = [fd.get("entity")] + metric = utils.get_metric_name(fd.get("metric")) + secondary_metric = utils.get_metric_name(fd.get("secondary_metric")) + columns = ["country", "m1", "m2"] + if metric == secondary_metric: + ndf = df[cols] + ndf["m1"] = df[metric] + ndf["m2"] = ndf["m1"] + else: + if secondary_metric: + cols += [metric, secondary_metric] + else: + cols += [metric] + columns = ["country", "m1"] + ndf = df[cols] + df = ndf + df.columns = columns + d = df.to_dict(orient="records") + for row in d: + country = None + if isinstance(row["country"], str): + if "country_fieldtype" in fd: + country = countries.get(fd["country_fieldtype"], row["country"]) + if country: + row["country"] = country["cca3"] + row["latitude"] = country["lat"] + row["longitude"] = country["lng"] + row["name"] = country["name"] + else: + row["country"] = "XXX" + return d + + +class FilterBoxViz(BaseViz): + + """A multi filter, multi-choice filter box to make dashboards interactive""" + + viz_type = "filter_box" + verbose_name = _("Filters") + is_timeseries = False + credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original' + cache_type = "get_data" + filter_row_limit = 1000 + + def query_obj(self): + return None + + def run_extra_queries(self): + qry = super().query_obj() + filters = self.form_data.get("filter_configs") or [] + qry["row_limit"] = self.filter_row_limit + self.dataframes = {} + for flt in filters: + col = flt.get("column") + if not col: + raise Exception( + _("Invalid filter configuration, please select a column") + ) + qry["columns"] = [col] + metric = flt.get("metric") + qry["metrics"] = [metric] if metric else [] + df = self.get_df_payload(query_obj=qry).get("df") + self.dataframes[col] = df + + def get_data(self, df: pd.DataFrame) -> VizData: + filters = self.form_data.get("filter_configs") or [] + d = {} + for flt in filters: + col = flt.get("column") + metric = flt.get("metric") + df = self.dataframes.get(col) + if df is not None: + if metric: + df = df.sort_values( + utils.get_metric_name(metric), ascending=flt.get("asc") + ) + d[col] = [ + {"id": row[0], "text": row[0], "metric": row[1]} + for row in df.itertuples(index=False) + ] + else: + df = df.sort_values(col, ascending=flt.get("asc")) + d[col] = [ + {"id": row[0], "text": row[0]} + for row in df.itertuples(index=False) + ] + return d + + +class IFrameViz(BaseViz): + + """You can squeeze just about anything in this iFrame component""" + + viz_type = "iframe" + verbose_name = _("iFrame") + credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original' + is_timeseries = False + + def query_obj(self): + return None + + def get_df(self, query_obj: Optional[Dict[str, Any]] = None) -> pd.DataFrame: + return pd.DataFrame() + + def get_data(self, df: pd.DataFrame) -> VizData: + return {"iframe": True} + + +class ParallelCoordinatesViz(BaseViz): + + """Interactive parallel coordinate implementation + + Uses this amazing javascript library + https://github.com/syntagmatic/parallel-coordinates + """ + + viz_type = "para" + verbose_name = _("Parallel Coordinates") + credits = ( + '<a href="https://syntagmatic.github.io/parallel-coordinates/">' + "Syntagmatic's library</a>" + ) + is_timeseries = False + + def get_data(self, df: pd.DataFrame) -> VizData: + return df.to_dict(orient="records") + + +class HeatmapViz(BaseViz): + + """A nice heatmap visualization that support high density through canvas""" + + viz_type = "heatmap" + verbose_name = _("Heatmap") + is_timeseries = False + credits = ( + 'inspired from mbostock @<a href="http://bl.ocks.org/mbostock/3074470">' + "bl.ocks.org</a>" + ) + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + fd = self.form_data + x = fd.get("all_columns_x") + y = fd.get("all_columns_y") + v = self.metric_labels[0] + if x == y: + df.columns = ["x", "y", "v"] + else: + df = df[[x, y, v]] + df.columns = ["x", "y", "v"] + norm = fd.get("normalize_across") + overall = False + max_ = df.v.max() + min_ = df.v.min() + if norm == "heatmap": + overall = True + else: + gb = df.groupby(norm, group_keys=False) + if len(gb) <= 1: + overall = True + else: + df["perc"] = gb.apply( + lambda x: (x.v - x.v.min()) / (x.v.max() - x.v.min()) + ) + df["rank"] = gb.apply(lambda x: x.v.rank(pct=True)) + if overall: + df["perc"] = (df.v - min_) / (max_ - min_) + df["rank"] = df.v.rank(pct=True) + return {"records": df.to_dict(orient="records"), "extents": [min_, max_]} + + +class HorizonViz(NVD3TimeSeriesViz): + + """Horizon chart + + https://www.npmjs.com/package/d3-horizon-chart + """ + + viz_type = "horizon" + verbose_name = _("Horizon Charts") + credits = ( + '<a href="https://www.npmjs.com/package/d3-horizon-chart">' + "d3-horizon-chart</a>" + ) + + +class MapboxViz(BaseViz): + + """Rich maps made with Mapbox""" + + viz_type = "mapbox" + verbose_name = _("Mapbox") + is_timeseries = False + credits = "<a href=https://www.mapbox.com/mapbox-gl-js/api/>Mapbox GL JS</a>" + + def query_obj(self): + d = super().query_obj() + fd = self.form_data + label_col = fd.get("mapbox_label") + + if not fd.get("groupby"): + if fd.get("all_columns_x") is None or fd.get("all_columns_y") is None: + raise Exception(_("[Longitude] and [Latitude] must be set")) + d["columns"] = [fd.get("all_columns_x"), fd.get("all_columns_y")] + + if label_col and len(label_col) >= 1: + if label_col[0] == "count": + raise Exception( + _( + "Must have a [Group By] column to have 'count' as the " + + "[Label]" + ) + ) + d["columns"].append(label_col[0]) + + if fd.get("point_radius") != "Auto": + d["columns"].append(fd.get("point_radius")) + + d["columns"] = list(set(d["columns"])) + else: + # Ensuring columns chosen are all in group by + if ( + label_col + and len(label_col) >= 1 + and label_col[0] != "count" + and label_col[0] not in fd.get("groupby") + ): + raise Exception(_("Choice of [Label] must be present in [Group By]")) + + if fd.get("point_radius") != "Auto" and fd.get( + "point_radius" + ) not in fd.get("groupby"): + raise Exception( + _("Choice of [Point Radius] must be present in [Group By]") + ) + + if fd.get("all_columns_x") not in fd.get("groupby") or fd.get( + "all_columns_y" + ) not in fd.get("groupby"): + raise Exception( + _( + "[Longitude] and [Latitude] columns must be present in " + + "[Group By]" + ) + ) + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + fd = self.form_data + label_col = fd.get("mapbox_label") + has_custom_metric = label_col is not None and len(label_col) > 0 + metric_col = [None] * len(df.index) + if has_custom_metric: + if label_col[0] == fd.get("all_columns_x"): # type: ignore + metric_col = df[fd.get("all_columns_x")] + elif label_col[0] == fd.get("all_columns_y"): # type: ignore + metric_col = df[fd.get("all_columns_y")] + else: + metric_col = df[label_col[0]] # type: ignore + point_radius_col = ( + [None] * len(df.index) + if fd.get("point_radius") == "Auto" + else df[fd.get("point_radius")] + ) + + # limiting geo precision as long decimal values trigger issues + # around json-bignumber in Mapbox + GEO_PRECISION = 10 + # using geoJSON formatting + geo_json = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"metric": metric, "radius": point_radius}, + "geometry": { + "type": "Point", + "coordinates": [ + round(lon, GEO_PRECISION), + round(lat, GEO_PRECISION), + ], + }, + } + for lon, lat, metric, point_radius in zip( + df[fd.get("all_columns_x")], + df[fd.get("all_columns_y")], + metric_col, + point_radius_col, + ) + ], + } + + x_series, y_series = df[fd.get("all_columns_x")], df[fd.get("all_columns_y")] + south_west = [x_series.min(), y_series.min()] + north_east = [x_series.max(), y_series.max()] + + return { + "geoJSON": geo_json, + "hasCustomMetric": has_custom_metric, + "mapboxApiKey": config["MAPBOX_API_KEY"], + "mapStyle": fd.get("mapbox_style"), + "aggregatorName": fd.get("pandas_aggfunc"), + "clusteringRadius": fd.get("clustering_radius"), + "pointRadiusUnit": fd.get("point_radius_unit"), + "globalOpacity": fd.get("global_opacity"), + "bounds": [south_west, north_east], + "renderWhileDragging": fd.get("render_while_dragging"), + "tooltip": fd.get("rich_tooltip"), + "color": fd.get("mapbox_color"), + } + + +class DeckGLMultiLayer(BaseViz): + + """Pile on multiple DeckGL layers""" + + viz_type = "deck_multi" + verbose_name = _("Deck.gl - Multiple Layers") + + is_timeseries = False + credits = '<a href="https://uber.github.io/deck.gl/">deck.gl</a>' + + def query_obj(self): + return None + + def get_data(self, df: pd.DataFrame) -> VizData: + fd = self.form_data + # Late imports to avoid circular import issues + from superset.models.slice import Slice + from superset import db + + slice_ids = fd.get("deck_slices") + slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all() + return { + "mapboxApiKey": config["MAPBOX_API_KEY"], + "slices": [slc.data for slc in slices], + } + + +class BaseDeckGLViz(BaseViz): + + """Base class for deck.gl visualizations""" + + is_timeseries = False + credits = '<a href="https://uber.github.io/deck.gl/">deck.gl</a>' + spatial_control_keys: List[str] = [] + + def get_metrics(self): + self.metric = self.form_data.get("size") + return [self.metric] if self.metric else [] + + @staticmethod + def parse_coordinates(s): + if not s: + return None + try: + p = Point(s) + return (p.latitude, p.longitude) # pylint: disable=no-member + except Exception: + raise SpatialException(_("Invalid spatial point encountered: %s" % s)) + + @staticmethod + def reverse_geohash_decode(geohash_code): + lat, lng = geohash.decode(geohash_code) + return (lng, lat) + + @staticmethod + def reverse_latlong(df, key): + df[key] = [tuple(reversed(o)) for o in df[key] if isinstance(o, (list, tuple))] + + def process_spatial_data_obj(self, key, df): + spatial = self.form_data.get(key) + if spatial is None: + raise ValueError(_("Bad spatial key")) + + if spatial.get("type") == "latlong": + df[key] = list( + zip( + pd.to_numeric(df[spatial.get("lonCol")], errors="coerce"), + pd.to_numeric(df[spatial.get("latCol")], errors="coerce"), + ) + ) + elif spatial.get("type") == "delimited": + lon_lat_col = spatial.get("lonlatCol") + df[key] = df[lon_lat_col].apply(self.parse_coordinates) + del df[lon_lat_col] + elif spatial.get("type") == "geohash": + df[key] = df[spatial.get("geohashCol")].map(self.reverse_geohash_decode) + del df[spatial.get("geohashCol")] + + if spatial.get("reverseCheckbox"): + self.reverse_latlong(df, key) + + if df.get(key) is None: + raise NullValueException( + _( + "Encountered invalid NULL spatial entry, \ + please consider filtering those out" + ) + ) + return df + + def add_null_filters(self): + fd = self.form_data + spatial_columns = set() + + if fd.get("adhoc_filters") is None: + fd["adhoc_filters"] = [] + + line_column = fd.get("line_column") + if line_column: + spatial_columns.add(line_column) + + for column in sorted(spatial_columns): + filter_ = to_adhoc({"col": column, "op": "IS NOT NULL", "val": ""}) + fd["adhoc_filters"].append(filter_) + + def query_obj(self): + fd = self.form_data + + # add NULL filters + if fd.get("filter_nulls", True): + self.add_null_filters() + + d = super().query_obj() + + metrics = self.get_metrics() + if metrics: + d["metrics"] = metrics + return d + + def get_js_columns(self, d): + cols = self.form_data.get("js_columns") or [] + return {col: d.get(col) for col in cols} + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + # Processing spatial info + for key in self.spatial_control_keys: + df = self.process_spatial_data_obj(key, df) + + features = [] + for d in df.to_dict(orient="records"): + feature = self.get_properties(d) + extra_props = self.get_js_columns(d) + if extra_props: + feature["extraProps"] = extra_props + features.append(feature) + + return { + "features": features, + "mapboxApiKey": config["MAPBOX_API_KEY"], + "metricLabels": self.metric_labels, + } + + def get_properties(self, d): + raise NotImplementedError() + + +class DeckScatterViz(BaseDeckGLViz): + + """deck.gl's ScatterLayer""" + + viz_type = "deck_scatter" + verbose_name = _("Deck.gl - Scatter plot") + spatial_control_keys = ["spatial"] + is_timeseries = True + + def query_obj(self): + fd = self.form_data + self.is_timeseries = bool(fd.get("time_grain_sqla") or fd.get("granularity")) + self.point_radius_fixed = fd.get("point_radius_fixed") or { + "type": "fix", + "value": 500, + } + return super().query_obj() + + def get_metrics(self): + self.metric = None + if self.point_radius_fixed.get("type") == "metric": + self.metric = self.point_radius_fixed.get("value") + return [self.metric] + return None + + def get_properties(self, d): + return { + "metric": d.get(self.metric_label), + "radius": self.fixed_value + if self.fixed_value + else d.get(self.metric_label), + "cat_color": d.get(self.dim) if self.dim else None, + "position": d.get("spatial"), + DTTM_ALIAS: d.get(DTTM_ALIAS), + } + + def get_data(self, df: pd.DataFrame) -> VizData: + fd = self.form_data + self.metric_label = utils.get_metric_name(self.metric) if self.metric else None + self.point_radius_fixed = fd.get("point_radius_fixed") + self.fixed_value = None + self.dim = self.form_data.get("dimension") + if self.point_radius_fixed and self.point_radius_fixed.get("type") != "metric": + self.fixed_value = self.point_radius_fixed.get("value") + return super().get_data(df) + + +class DeckScreengrid(BaseDeckGLViz): + + """deck.gl's ScreenGridLayer""" + + viz_type = "deck_screengrid" + verbose_name = _("Deck.gl - Screen Grid") + spatial_control_keys = ["spatial"] + is_timeseries = True + + def query_obj(self): + fd = self.form_data + self.is_timeseries = fd.get("time_grain_sqla") or fd.get("granularity") + return super().query_obj() + + def get_properties(self, d): + return { + "position": d.get("spatial"), + "weight": d.get(self.metric_label) or 1, + "__timestamp": d.get(DTTM_ALIAS) or d.get("__time"), + } + + def get_data(self, df: pd.DataFrame) -> VizData: + self.metric_label = utils.get_metric_name(self.metric) + return super().get_data(df) + + +class DeckGrid(BaseDeckGLViz): + + """deck.gl's DeckLayer""" + + viz_type = "deck_grid" + verbose_name = _("Deck.gl - 3D Grid") + spatial_control_keys = ["spatial"] + + def get_properties(self, d): + return {"position": d.get("spatial"), "weight": d.get(self.metric_label) or 1} + + def get_data(self, df: pd.DataFrame) -> VizData: + self.metric_label = utils.get_metric_name(self.metric) + return super().get_data(df) + + +def geohash_to_json(geohash_code): + p = geohash.bbox(geohash_code) + return [ + [p.get("w"), p.get("n")], + [p.get("e"), p.get("n")], + [p.get("e"), p.get("s")], + [p.get("w"), p.get("s")], + [p.get("w"), p.get("n")], + ] + + +class DeckPathViz(BaseDeckGLViz): + + """deck.gl's PathLayer""" + + viz_type = "deck_path" + verbose_name = _("Deck.gl - Paths") + deck_viz_key = "path" + is_timeseries = True + deser_map = { + "json": json.loads, + "polyline": polyline.decode, + "geohash": geohash_to_json, + } + + def query_obj(self): + fd = self.form_data + self.is_timeseries = fd.get("time_grain_sqla") or fd.get("granularity") + d = super().query_obj() + self.metric = fd.get("metric") + if d["metrics"]: + self.has_metrics = True + else: + self.has_metrics = False + return d + + def get_properties(self, d): + fd = self.form_data + line_type = fd.get("line_type") + deser = self.deser_map[line_type] + line_column = fd.get("line_column") + path = deser(d[line_column]) + if fd.get("reverse_long_lat"): + path = [(o[1], o[0]) for o in path] + d[self.deck_viz_key] = path + if line_type != "geohash": + del d[line_column] + d["__timestamp"] = d.get(DTTM_ALIAS) or d.get("__time") + return d + + def get_data(self, df: pd.DataFrame) -> VizData: + self.metric_label = utils.get_metric_name(self.metric) + return super().get_data(df) + + +class DeckPolygon(DeckPathViz): + + """deck.gl's Polygon Layer""" + + viz_type = "deck_polygon" + deck_viz_key = "polygon" + verbose_name = _("Deck.gl - Polygon") + + def query_obj(self): + fd = self.form_data + self.elevation = fd.get("point_radius_fixed") or {"type": "fix", "value": 500} + return super().query_obj() + + def get_metrics(self): + metrics = [self.form_data.get("metric")] + if self.elevation.get("type") == "metric": + metrics.append(self.elevation.get("value")) + return [metric for metric in metrics if metric] + + def get_properties(self, d): + super().get_properties(d) + fd = self.form_data + elevation = fd["point_radius_fixed"]["value"] + type_ = fd["point_radius_fixed"]["type"] + d["elevation"] = ( + d.get(utils.get_metric_name(elevation)) if type_ == "metric" else elevation + ) + return d + + +class DeckHex(BaseDeckGLViz): + + """deck.gl's DeckLayer""" + + viz_type = "deck_hex" + verbose_name = _("Deck.gl - 3D HEX") + spatial_control_keys = ["spatial"] + + def get_properties(self, d): + return {"position": d.get("spatial"), "weight": d.get(self.metric_label) or 1} + + def get_data(self, df: pd.DataFrame) -> VizData: + self.metric_label = utils.get_metric_name(self.metric) + return super(DeckHex, self).get_data(df) + + +class DeckGeoJson(BaseDeckGLViz): + + """deck.gl's GeoJSONLayer""" + + viz_type = "deck_geojson" + verbose_name = _("Deck.gl - GeoJSON") + + def get_properties(self, d): + geojson = d.get(self.form_data.get("geojson")) + return json.loads(geojson) + + +class DeckArc(BaseDeckGLViz): + + """deck.gl's Arc Layer""" + + viz_type = "deck_arc" + verbose_name = _("Deck.gl - Arc") + spatial_control_keys = ["start_spatial", "end_spatial"] + is_timeseries = True + + def query_obj(self): + fd = self.form_data + self.is_timeseries = bool(fd.get("time_grain_sqla") or fd.get("granularity")) + return super().query_obj() + + def get_properties(self, d): + dim = self.form_data.get("dimension") + return { + "sourcePosition": d.get("start_spatial"), + "targetPosition": d.get("end_spatial"), + "cat_color": d.get(dim) if dim else None, + DTTM_ALIAS: d.get(DTTM_ALIAS), + } + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + d = super().get_data(df) + + return { + "features": d["features"], # type: ignore + "mapboxApiKey": config["MAPBOX_API_KEY"], + } + + +class EventFlowViz(BaseViz): + + """A visualization to explore patterns in event sequences""" + + viz_type = "event_flow" + verbose_name = _("Event flow") + credits = 'from <a href="https://github.com/williaster/data-ui">@data-ui</a>' + is_timeseries = True + + def query_obj(self): + query = super().query_obj() + form_data = self.form_data + + event_key = form_data.get("all_columns_x") + entity_key = form_data.get("entity") + meta_keys = [ + col + for col in form_data.get("all_columns") + if col != event_key and col != entity_key + ] + + query["columns"] = [event_key, entity_key] + meta_keys + + if form_data["order_by_entity"]: + query["orderby"] = [(entity_key, True)] + + return query + + def get_data(self, df: pd.DataFrame) -> VizData: + return df.to_dict(orient="records") + + +class PairedTTestViz(BaseViz): + + """A table displaying paired t-test values""" + + viz_type = "paired_ttest" + verbose_name = _("Time Series - Paired t-test") + sort_series = False + is_timeseries = True + + def get_data(self, df: pd.DataFrame) -> VizData: + """ + Transform received data frame into an object of the form: + { + 'metric1': [ + { + groups: ('groupA', ... ), + values: [ {x, y}, ... ], + }, ... + ], ... + } + """ + + if df.empty: + return None + + fd = self.form_data + groups = fd.get("groupby") + metrics = self.metric_labels + df = df.pivot_table(index=DTTM_ALIAS, columns=groups, values=metrics) + cols = [] + # Be rid of falsey keys + for col in df.columns: + if col == "": + cols.append("N/A") + elif col is None: + cols.append("NULL") + else: + cols.append(col) + df.columns = cols + data: Dict = {} + series = df.to_dict("series") + for nameSet in df.columns: + # If no groups are defined, nameSet will be the metric name + hasGroup = not isinstance(nameSet, str) + Y = series[nameSet] + d = { + "group": nameSet[1:] if hasGroup else "All", + "values": [{"x": t, "y": Y[t] if t in Y else None} for t in df.index], + } + key = nameSet[0] if hasGroup else nameSet + if key in data: + data[key].append(d) + else: + data[key] = [d] + return data + + +class RoseViz(NVD3TimeSeriesViz): + + viz_type = "rose" + verbose_name = _("Time Series - Nightingale Rose Chart") + sort_series = False + is_timeseries = True + + def get_data(self, df: pd.DataFrame) -> VizData: + if df.empty: + return None + + data = super().get_data(df) + result: Dict = {} + for datum in data: # type: ignore + key = datum["key"] + for val in datum["values"]: + timestamp = val["x"].value + if not result.get(timestamp): + result[timestamp] = [] + value = 0 if math.isnan(val["y"]) else val["y"] + result[timestamp].append( + { + "key": key, + "value": value, + "name": ", ".join(key) if isinstance(key, list) else key, + "time": val["x"], + } + ) + return result + + +class PartitionViz(NVD3TimeSeriesViz): + + """ + A hierarchical data visualization with support for time series. + """ + + viz_type = "partition" + verbose_name = _("Partition Diagram") + + def query_obj(self): + query_obj = super().query_obj() + time_op = self.form_data.get("time_series_option", "not_time") + # Return time series data if the user specifies so + query_obj["is_timeseries"] = time_op != "not_time" + return query_obj + + def levels_for(self, time_op, groups, df): + """ + Compute the partition at each `level` from the dataframe. + """ + levels = {} + for i in range(0, len(groups) + 1): + agg_df = df.groupby(groups[:i]) if i else df + levels[i] = ( + agg_df.mean() + if time_op == "agg_mean" + else agg_df.sum(numeric_only=True) + ) + return levels + + def levels_for_diff(self, time_op, groups, df): + # Obtain a unique list of the time grains + times = list(set(df[DTTM_ALIAS])) + times.sort() + until = times[len(times) - 1] + since = times[0] + # Function describing how to calculate the difference + func = { + "point_diff": [pd.Series.sub, lambda a, b, fill_value: a - b], + "point_factor": [pd.Series.div, lambda a, b, fill_value: a / float(b)], + "point_percent": [ + lambda a, b, fill_value=0: a.div(b, fill_value=fill_value) - 1, + lambda a, b, fill_value: a / float(b) - 1, + ], + }[time_op] + agg_df = df.groupby(DTTM_ALIAS).sum() + levels = { + 0: pd.Series( + { + m: func[1](agg_df[m][until], agg_df[m][since], 0) + for m in agg_df.columns + } + ) + } + for i in range(1, len(groups) + 1): + agg_df = df.groupby([DTTM_ALIAS] + groups[:i]).sum() + levels[i] = pd.DataFrame( + { + m: func[0](agg_df[m][until], agg_df[m][since], fill_value=0) + for m in agg_df.columns + } + ) + return levels + + def levels_for_time(self, groups, df): + procs = {} + for i in range(0, len(groups) + 1): + self.form_data["groupby"] = groups[:i] + df_drop = df.drop(groups[i:], 1) + procs[i] = self.process_data(df_drop, aggregate=True) + self.form_data["groupby"] = groups + return procs + + def nest_values(self, levels, level=0, metric=None, dims=()): + """ + Nest values at each level on the back-end with + access and setting, instead of summing from the bottom. + """ + if not level: + return [ + { + "name": m, + "val": levels[0][m], + "children": self.nest_values(levels, 1, m), + } + for m in levels[0].index + ] + if level == 1: + return [ + { + "name": i, + "val": levels[1][metric][i], + "children": self.nest_values(levels, 2, metric, (i,)), + } + for i in levels[1][metric].index + ] + if level >= len(levels): + return [] + return [ + { + "name": i, + "val": levels[level][metric][dims][i], + "children": self.nest_values(levels, level + 1, metric, dims + (i,)), + } + for i in levels[level][metric][dims].index + ] + + def nest_procs(self, procs, level=-1, dims=(), time=None): + if level == -1: + return [ + {"name": m, "children": self.nest_procs(procs, 0, (m,))} + for m in procs[0].columns + ] + if not level: + return [ + { + "name": t, + "val": procs[0][dims[0]][t], + "children": self.nest_procs(procs, 1, dims, t), + } + for t in procs[0].index + ] + if level >= len(procs): + return [] + return [ + { + "name": i, + "val": procs[level][dims][i][time], + "children": self.nest_procs(procs, level + 1, dims + (i,), time), + } + for i in procs[level][dims].columns + ] + + def get_data(self, df: pd.DataFrame) -> VizData: + fd = self.form_data + groups = fd.get("groupby", []) + time_op = fd.get("time_series_option", "not_time") + if not len(groups): + raise ValueError("Please choose at least one groupby") + if time_op == "not_time": + levels = self.levels_for("agg_sum", groups, df) + elif time_op in ["agg_sum", "agg_mean"]: + levels = self.levels_for(time_op, groups, df) + elif time_op in ["point_diff", "point_factor", "point_percent"]: + levels = self.levels_for_diff(time_op, groups, df) + elif time_op == "adv_anal": + procs = self.levels_for_time(groups, df) + return self.nest_procs(procs) + else: + levels = self.levels_for("agg_sum", [DTTM_ALIAS] + groups, df) + return self.nest_values(levels) + + +viz_types = { + o.viz_type: o + for o in globals().values() + if ( + inspect.isclass(o) + and issubclass(o, BaseViz) + and o.viz_type not in config["VIZ_TYPE_BLACKLIST"] + ) +} diff --git a/tests/access_tests.py b/tests/access_tests.py index 63cbfb5c7054..58affb640e42 100644 --- a/tests/access_tests.py +++ b/tests/access_tests.py @@ -567,10 +567,7 @@ def test_request_access(self): self.get_resp(ACCESS_REQUEST.format("druid", druid_ds_4_id, "go")) access_request4 = self.get_access_requests("gamma", "druid", druid_ds_4_id) - self.assertEqual( - access_request4.roles_with_datasource, - "<ul></ul>".format(access_request4.id), - ) + self.assertEqual(access_request4.roles_with_datasource, "<ul></ul>") # Case 5. Roles exist that contains the druid datasource. # add druid ds to the existing roles diff --git a/tests/base_api_tests.py b/tests/base_api_tests.py index 7a0481301a30..112211abb50e 100644 --- a/tests/base_api_tests.py +++ b/tests/base_api_tests.py @@ -158,20 +158,20 @@ def test_get_filter_related_owners(self): API: Test get filter related owners """ self.login(username="admin") - argument = {"filter": "a"} + argument = {"filter": "gamma"} uri = f"api/v1/{self.resource_name}/related/owners?q={prison.dumps(argument)}" rv = self.client.get(uri) self.assertEqual(rv.status_code, 200) response = json.loads(rv.data.decode("utf-8")) - expected_response = { - "count": 2, - "result": [ - {"text": "admin user", "value": 1}, - {"text": "alpha user", "value": 5}, - ], - } - self.assertEqual(response, expected_response) + self.assertEqual(3, response["count"]) + sorted_results = sorted(response["result"], key=lambda value: value["text"]) + expected_results = [ + {"text": "gamma user", "value": 2}, + {"text": "gamma2 user", "value": 3}, + {"text": "gamma_sqllab user", "value": 4}, + ] + self.assertEqual(expected_results, sorted_results) def test_get_related_fail(self): """ diff --git a/tests/base_tests.py b/tests/base_tests.py index 7ccbf0a27b5a..370adf306775 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -18,10 +18,11 @@ """Unit tests for Superset""" import imp import json -from typing import Union -from unittest.mock import Mock +from typing import Union, Dict +from unittest.mock import Mock, patch import pandas as pd +from flask import Response from flask_appbuilder.security.sqla import models as ab_models from flask_testing import TestCase @@ -35,6 +36,7 @@ from superset.models.dashboard import Dashboard from superset.models.datasource_access_request import DatasourceAccessRequest from superset.utils.core import get_example_database +from superset.views.base_api import BaseSupersetModelRestApi FAKE_DB_NAME = "fake_db_100" @@ -282,6 +284,28 @@ def delete_fake_db(self): if database: db.session.delete(database) + def create_fake_presto_db(self): + self.login(username="admin") + database_name = "presto" + db_id = 200 + return self.get_or_create( + cls=models.Database, + criteria={"database_name": database_name}, + session=db.session, + sqlalchemy_uri="presto://user@host:8080/hive", + id=db_id, + ) + + def delete_fake_presto_db(self): + database = ( + db.session.query(Database) + .filter(Database.database_name == "presto") + .scalar() + ) + if database: + db.session.delete(database) + db.session.commit() + def validate_sql( self, sql, @@ -306,3 +330,81 @@ def validate_sql( def get_dash_by_slug(self, dash_slug): sesh = db.session() return sesh.query(Dashboard).filter_by(slug=dash_slug).first() + + def get_assert_metric(self, uri: str, func_name: str) -> Response: + """ + Simple client get with an extra assertion for statsd metrics + + :param uri: The URI to use for the HTTP GET + :param func_name: The function name that the HTTP GET triggers + for the statsd metric assertion + :return: HTTP Response + """ + with patch.object( + BaseSupersetModelRestApi, "incr_stats", return_value=None + ) as mock_method: + rv = self.client.get(uri) + if 200 <= rv.status_code < 400: + mock_method.assert_called_once_with("success", func_name) + else: + mock_method.assert_called_once_with("error", func_name) + return rv + + def delete_assert_metric(self, uri: str, func_name: str) -> Response: + """ + Simple client delete with an extra assertion for statsd metrics + + :param uri: The URI to use for the HTTP DELETE + :param func_name: The function name that the HTTP DELETE triggers + for the statsd metric assertion + :return: HTTP Response + """ + with patch.object( + BaseSupersetModelRestApi, "incr_stats", return_value=None + ) as mock_method: + rv = self.client.delete(uri) + if 200 <= rv.status_code < 400: + mock_method.assert_called_once_with("success", func_name) + else: + mock_method.assert_called_once_with("error", func_name) + return rv + + def post_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response: + """ + Simple client post with an extra assertion for statsd metrics + + :param uri: The URI to use for the HTTP POST + :param data: The JSON data payload to be posted + :param func_name: The function name that the HTTP POST triggers + for the statsd metric assertion + :return: HTTP Response + """ + with patch.object( + BaseSupersetModelRestApi, "incr_stats", return_value=None + ) as mock_method: + rv = self.client.post(uri, json=data) + if 200 <= rv.status_code < 400: + mock_method.assert_called_once_with("success", func_name) + else: + mock_method.assert_called_once_with("error", func_name) + return rv + + def put_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response: + """ + Simple client put with an extra assertion for statsd metrics + + :param uri: The URI to use for the HTTP PUT + :param data: The JSON data payload to be posted + :param func_name: The function name that the HTTP PUT triggers + for the statsd metric assertion + :return: HTTP Response + """ + with patch.object( + BaseSupersetModelRestApi, "incr_stats", return_value=None + ) as mock_method: + rv = self.client.put(uri, json=data) + if 200 <= rv.status_code < 400: + mock_method.assert_called_once_with("success", func_name) + else: + mock_method.assert_called_once_with("error", func_name) + return rv diff --git a/tests/charts/__init__.py b/tests/charts/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/tests/charts/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/chart_api_tests.py b/tests/charts/api_tests.py similarity index 52% rename from tests/chart_api_tests.py rename to tests/charts/api_tests.py index 307d4add96d7..0fb847146038 100644 --- a/tests/chart_api_tests.py +++ b/tests/charts/api_tests.py @@ -16,17 +16,18 @@ # under the License. """Unit tests for Superset""" import json -from typing import List, Optional +from typing import Any, Dict, List, Optional import prison +from sqlalchemy.sql import func -from superset import db, security_manager +import tests.test_app from superset.connectors.connector_registry import ConnectorRegistry +from superset.extensions import db, security_manager from superset.models.dashboard import Dashboard from superset.models.slice import Slice - -from .base_api_tests import ApiOwnersTestCaseMixin -from .base_tests import SupersetTestCase +from tests.base_api_tests import ApiOwnersTestCaseMixin +from tests.base_tests import SupersetTestCase class ChartApiTests(SupersetTestCase, ApiOwnersTestCaseMixin): @@ -68,46 +69,143 @@ def insert_chart( db.session.commit() return slice + def _get_query_context(self) -> Dict[str, Any]: + self.login(username="admin") + slc = self.get_slice("Girl Name Cloud", db.session) + return { + "datasource": {"id": slc.datasource_id, "type": slc.datasource_type}, + "queries": [ + { + "extras": {"where": ""}, + "granularity": "ds", + "groupby": ["name"], + "is_timeseries": False, + "metrics": [{"label": "sum__num"}], + "order_desc": True, + "orderby": [], + "row_limit": 100, + "time_range": "100 years ago : now", + "timeseries_limit": 0, + "timeseries_limit_metric": None, + "filters": [{"col": "gender", "op": "==", "val": "boy"}], + "having": "", + "having_filters": [], + "where": "", + } + ], + } + def test_delete_chart(self): """ - Chart API: Test delete + Chart API: Test delete """ admin_id = self.get_user("admin").id chart_id = self.insert_chart("name", [admin_id], 1).id self.login(username="admin") uri = f"api/v1/chart/{chart_id}" - rv = self.client.delete(uri) + rv = self.delete_assert_metric(uri, "delete") self.assertEqual(rv.status_code, 200) model = db.session.query(Slice).get(chart_id) self.assertEqual(model, None) + def test_delete_bulk_charts(self): + """ + Chart API: Test delete bulk + """ + admin_id = self.get_user("admin").id + chart_count = 4 + chart_ids = list() + for chart_name_index in range(chart_count): + chart_ids.append( + self.insert_chart(f"title{chart_name_index}", [admin_id], 1).id + ) + self.login(username="admin") + argument = chart_ids + uri = f"api/v1/chart/?q={prison.dumps(argument)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + self.assertEqual(rv.status_code, 200) + response = json.loads(rv.data.decode("utf-8")) + expected_response = {"message": f"Deleted {chart_count} charts"} + self.assertEqual(response, expected_response) + for chart_id in chart_ids: + model = db.session.query(Slice).get(chart_id) + self.assertEqual(model, None) + + def test_delete_bulk_chart_bad_request(self): + """ + Chart API: Test delete bulk bad request + """ + chart_ids = [1, "a"] + self.login(username="admin") + argument = chart_ids + uri = f"api/v1/chart/?q={prison.dumps(argument)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + self.assertEqual(rv.status_code, 400) + def test_delete_not_found_chart(self): """ - Chart API: Test not found delete + Chart API: Test not found delete """ self.login(username="admin") chart_id = 1000 uri = f"api/v1/chart/{chart_id}" - rv = self.client.delete(uri) + rv = self.delete_assert_metric(uri, "delete") + self.assertEqual(rv.status_code, 404) + + def test_delete_bulk_charts_not_found(self): + """ + Chart API: Test delete bulk not found + """ + max_id = db.session.query(func.max(Slice.id)).scalar() + chart_ids = [max_id + 1, max_id + 2] + self.login(username="admin") + argument = chart_ids + uri = f"api/v1/chart/?q={prison.dumps(argument)}" + rv = self.delete_assert_metric(uri, "bulk_delete") self.assertEqual(rv.status_code, 404) def test_delete_chart_admin_not_owned(self): """ - Chart API: Test admin delete not owned + Chart API: Test admin delete not owned """ gamma_id = self.get_user("gamma").id chart_id = self.insert_chart("title", [gamma_id], 1).id self.login(username="admin") uri = f"api/v1/chart/{chart_id}" - rv = self.client.delete(uri) + rv = self.delete_assert_metric(uri, "delete") self.assertEqual(rv.status_code, 200) model = db.session.query(Slice).get(chart_id) self.assertEqual(model, None) + def test_delete_bulk_chart_admin_not_owned(self): + """ + Chart API: Test admin delete bulk not owned + """ + gamma_id = self.get_user("gamma").id + chart_count = 4 + chart_ids = list() + for chart_name_index in range(chart_count): + chart_ids.append( + self.insert_chart(f"title{chart_name_index}", [gamma_id], 1).id + ) + + self.login(username="admin") + argument = chart_ids + uri = f"api/v1/chart/?q={prison.dumps(argument)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + response = json.loads(rv.data.decode("utf-8")) + self.assertEqual(rv.status_code, 200) + expected_response = {"message": f"Deleted {chart_count} charts"} + self.assertEqual(response, expected_response) + + for chart_id in chart_ids: + model = db.session.query(Slice).get(chart_id) + self.assertEqual(model, None) + def test_delete_chart_not_owned(self): """ - Chart API: Test delete try not owned + Chart API: Test delete try not owned """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -118,16 +216,63 @@ def test_delete_chart_not_owned(self): chart = self.insert_chart("title", [user_alpha1.id], 1) self.login(username="alpha2", password="password") uri = f"api/v1/chart/{chart.id}" - rv = self.client.delete(uri) + rv = self.delete_assert_metric(uri, "delete") self.assertEqual(rv.status_code, 403) db.session.delete(chart) db.session.delete(user_alpha1) db.session.delete(user_alpha2) db.session.commit() + def test_delete_bulk_chart_not_owned(self): + """ + Chart API: Test delete bulk try not owned + """ + user_alpha1 = self.create_user( + "alpha1", "password", "Alpha", email="alpha1@superset.org" + ) + user_alpha2 = self.create_user( + "alpha2", "password", "Alpha", email="alpha2@superset.org" + ) + + chart_count = 4 + charts = list() + for chart_name_index in range(chart_count): + charts.append( + self.insert_chart(f"title{chart_name_index}", [user_alpha1.id], 1) + ) + + owned_chart = self.insert_chart("title_owned", [user_alpha2.id], 1) + + self.login(username="alpha2", password="password") + + # verify we can't delete not owned charts + arguments = [chart.id for chart in charts] + uri = f"api/v1/chart/?q={prison.dumps(arguments)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + self.assertEqual(rv.status_code, 403) + response = json.loads(rv.data.decode("utf-8")) + expected_response = {"message": "Forbidden"} + self.assertEqual(response, expected_response) + + # # nothing is deleted in bulk with a list of owned and not owned charts + arguments = [chart.id for chart in charts] + [owned_chart.id] + uri = f"api/v1/chart/?q={prison.dumps(arguments)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + self.assertEqual(rv.status_code, 403) + response = json.loads(rv.data.decode("utf-8")) + expected_response = {"message": "Forbidden"} + self.assertEqual(response, expected_response) + + for chart in charts: + db.session.delete(chart) + db.session.delete(owned_chart) + db.session.delete(user_alpha1) + db.session.delete(user_alpha2) + db.session.commit() + def test_create_chart(self): """ - Chart API: Test create chart + Chart API: Test create chart """ admin_id = self.get_user("admin").id chart_data = { @@ -143,7 +288,7 @@ def test_create_chart(self): } self.login(username="admin") uri = f"api/v1/chart/" - rv = self.client.post(uri, json=chart_data) + rv = self.post_assert_metric(uri, chart_data, "post") self.assertEqual(rv.status_code, 201) data = json.loads(rv.data.decode("utf-8")) model = db.session.query(Slice).get(data.get("id")) @@ -152,7 +297,7 @@ def test_create_chart(self): def test_create_simple_chart(self): """ - Chart API: Test create simple chart + Chart API: Test create simple chart """ chart_data = { "slice_name": "title1", @@ -161,7 +306,7 @@ def test_create_simple_chart(self): } self.login(username="admin") uri = f"api/v1/chart/" - rv = self.client.post(uri, json=chart_data) + rv = self.post_assert_metric(uri, chart_data, "post") self.assertEqual(rv.status_code, 201) data = json.loads(rv.data.decode("utf-8")) model = db.session.query(Slice).get(data.get("id")) @@ -170,7 +315,7 @@ def test_create_simple_chart(self): def test_create_chart_validate_owners(self): """ - Chart API: Test create validate owners + Chart API: Test create validate owners """ chart_data = { "slice_name": "title1", @@ -180,15 +325,15 @@ def test_create_chart_validate_owners(self): } self.login(username="admin") uri = f"api/v1/chart/" - rv = self.client.post(uri, json=chart_data) + rv = self.post_assert_metric(uri, chart_data, "post") self.assertEqual(rv.status_code, 422) response = json.loads(rv.data.decode("utf-8")) - expected_response = {"message": {"owners": {"0": ["User 1000 does not exist"]}}} + expected_response = {"message": {"owners": ["Owners are invalid"]}} self.assertEqual(response, expected_response) def test_create_chart_validate_params(self): """ - Chart API: Test create validate params json + Chart API: Test create validate params json """ chart_data = { "slice_name": "title1", @@ -198,12 +343,12 @@ def test_create_chart_validate_params(self): } self.login(username="admin") uri = f"api/v1/chart/" - rv = self.client.post(uri, json=chart_data) - self.assertEqual(rv.status_code, 422) + rv = self.post_assert_metric(uri, chart_data, "post") + self.assertEqual(rv.status_code, 400) def test_create_chart_validate_datasource(self): """ - Chart API: Test create validate datasource + Chart API: Test create validate datasource """ self.login(username="admin") chart_data = { @@ -212,12 +357,11 @@ def test_create_chart_validate_datasource(self): "datasource_type": "unknown", } uri = f"api/v1/chart/" - rv = self.client.post(uri, json=chart_data) + rv = self.post_assert_metric(uri, chart_data, "post") self.assertEqual(rv.status_code, 422) response = json.loads(rv.data.decode("utf-8")) self.assertEqual( - response, - {"message": {"_schema": ["Datasource [unknown].1 does not exist"]}}, + response, {"message": {"datasource_id": ["Datasource does not exist"]}} ) chart_data = { "slice_name": "title1", @@ -225,16 +369,16 @@ def test_create_chart_validate_datasource(self): "datasource_type": "table", } uri = f"api/v1/chart/" - rv = self.client.post(uri, json=chart_data) + rv = self.post_assert_metric(uri, chart_data, "post") self.assertEqual(rv.status_code, 422) response = json.loads(rv.data.decode("utf-8")) self.assertEqual( - response, {"message": {"_schema": ["Datasource [table].0 does not exist"]}} + response, {"message": {"datasource_id": ["Datasource does not exist"]}} ) def test_update_chart(self): """ - Chart API: Test update + Chart API: Test update """ admin = self.get_user("admin") gamma = self.get_user("gamma") @@ -253,7 +397,7 @@ def test_update_chart(self): } self.login(username="admin") uri = f"api/v1/chart/{chart_id}" - rv = self.client.put(uri, json=chart_data) + rv = self.put_assert_metric(uri, chart_data, "put") self.assertEqual(rv.status_code, 200) model = db.session.query(Slice).get(chart_id) related_dashboard = db.session.query(Dashboard).get(1) @@ -273,7 +417,7 @@ def test_update_chart(self): def test_update_chart_new_owner(self): """ - Chart API: Test update set new owner to current user + Chart API: Test update set new owner to current user """ gamma = self.get_user("gamma") admin = self.get_user("admin") @@ -281,7 +425,7 @@ def test_update_chart_new_owner(self): chart_data = {"slice_name": "title1_changed"} self.login(username="admin") uri = f"api/v1/chart/{chart_id}" - rv = self.client.put(uri, json=chart_data) + rv = self.put_assert_metric(uri, chart_data, "put") self.assertEqual(rv.status_code, 200) model = db.session.query(Slice).get(chart_id) self.assertIn(admin, model.owners) @@ -290,7 +434,7 @@ def test_update_chart_new_owner(self): def test_update_chart_not_owned(self): """ - Chart API: Test update not owned + Chart API: Test update not owned """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -303,7 +447,7 @@ def test_update_chart_not_owned(self): self.login(username="alpha2", password="password") chart_data = {"slice_name": "title1_changed"} uri = f"api/v1/chart/{chart.id}" - rv = self.client.put(uri, json=chart_data) + rv = self.put_assert_metric(uri, chart_data, "put") self.assertEqual(rv.status_code, 403) db.session.delete(chart) db.session.delete(user_alpha1) @@ -312,34 +456,33 @@ def test_update_chart_not_owned(self): def test_update_chart_validate_datasource(self): """ - Chart API: Test update validate datasource + Chart API: Test update validate datasource """ admin = self.get_user("admin") chart = self.insert_chart("title", [admin.id], 1) self.login(username="admin") chart_data = {"datasource_id": 1, "datasource_type": "unknown"} uri = f"api/v1/chart/{chart.id}" - rv = self.client.put(uri, json=chart_data) + rv = self.put_assert_metric(uri, chart_data, "put") self.assertEqual(rv.status_code, 422) response = json.loads(rv.data.decode("utf-8")) self.assertEqual( - response, - {"message": {"_schema": ["Datasource [unknown].1 does not exist"]}}, + response, {"message": {"datasource_id": ["Datasource does not exist"]}} ) chart_data = {"datasource_id": 0, "datasource_type": "table"} uri = f"api/v1/chart/{chart.id}" - rv = self.client.put(uri, json=chart_data) + rv = self.put_assert_metric(uri, chart_data, "put") self.assertEqual(rv.status_code, 422) response = json.loads(rv.data.decode("utf-8")) self.assertEqual( - response, {"message": {"_schema": ["Datasource [table].0 does not exist"]}} + response, {"message": {"datasource_id": ["Datasource does not exist"]}} ) db.session.delete(chart) db.session.commit() def test_update_chart_validate_owners(self): """ - Chart API: Test update validate owners + Chart API: Test update validate owners """ chart_data = { "slice_name": "title1", @@ -352,24 +495,31 @@ def test_update_chart_validate_owners(self): rv = self.client.post(uri, json=chart_data) self.assertEqual(rv.status_code, 422) response = json.loads(rv.data.decode("utf-8")) - expected_response = {"message": {"owners": {"0": ["User 1000 does not exist"]}}} + expected_response = {"message": {"owners": ["Owners are invalid"]}} self.assertEqual(response, expected_response) def test_get_chart(self): """ - Chart API: Test get chart + Chart API: Test get chart """ admin = self.get_user("admin") chart = self.insert_chart("title", [admin.id], 1) self.login(username="admin") uri = f"api/v1/chart/{chart.id}" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get") self.assertEqual(rv.status_code, 200) expected_result = { "cache_timeout": None, "dashboards": [], "description": None, - "owners": [{"id": 1, "username": "admin"}], + "owners": [ + { + "id": 1, + "username": "admin", + "first_name": "admin", + "last_name": "user", + } + ], "params": None, "slice_name": "title", "viz_type": None, @@ -381,17 +531,17 @@ def test_get_chart(self): def test_get_chart_not_found(self): """ - Chart API: Test get chart not found + Chart API: Test get chart not found """ chart_id = 1000 self.login(username="admin") uri = f"api/v1/chart/{chart_id}" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get") self.assertEqual(rv.status_code, 404) def test_get_chart_no_data_access(self): """ - Chart API: Test get chart without data access + Chart API: Test get chart without data access """ self.login(username="gamma") chart_no_access = ( @@ -405,30 +555,80 @@ def test_get_chart_no_data_access(self): def test_get_charts(self): """ - Chart API: Test get charts + Chart API: Test get charts """ self.login(username="admin") uri = f"api/v1/chart/" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get_list") self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data["count"], 33) def test_get_charts_filter(self): """ - Chart API: Test get charts filter + Chart API: Test get charts filter """ self.login(username="admin") arguments = {"filters": [{"col": "slice_name", "opr": "sw", "value": "G"}]} uri = f"api/v1/chart/?q={prison.dumps(arguments)}" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get_list") self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data["count"], 5) + def test_get_charts_custom_filter(self): + """ + Chart API: Test get charts custom filter + """ + admin = self.get_user("admin") + chart1 = self.insert_chart("foo", [admin.id], 1, description="ZY_bar") + chart2 = self.insert_chart("zy_foo", [admin.id], 1, description="desc1") + chart3 = self.insert_chart("foo", [admin.id], 1, description="desc1zy_") + chart4 = self.insert_chart("bar", [admin.id], 1, description="foo") + + arguments = { + "filters": [ + {"col": "slice_name", "opr": "name_or_description", "value": "zy_"} + ], + "order_column": "slice_name", + "order_direction": "asc", + } + self.login(username="admin") + uri = f"api/v1/chart/?q={prison.dumps(arguments)}" + rv = self.get_assert_metric(uri, "get_list") + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["count"], 3) + + expected_response = [ + {"description": "ZY_bar", "slice_name": "foo",}, + {"description": "desc1zy_", "slice_name": "foo",}, + {"description": "desc1", "slice_name": "zy_foo",}, + ] + for index, item in enumerate(data["result"]): + self.assertEqual( + item["description"], expected_response[index]["description"] + ) + self.assertEqual(item["slice_name"], expected_response[index]["slice_name"]) + + self.logout() + self.login(username="gamma") + uri = f"api/v1/chart/?q={prison.dumps(arguments)}" + rv = self.get_assert_metric(uri, "get_list") + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["count"], 0) + + # rollback changes + db.session.delete(chart1) + db.session.delete(chart2) + db.session.delete(chart3) + db.session.delete(chart4) + db.session.commit() + def test_get_charts_page(self): """ - Chart API: Test get charts filter + Chart API: Test get charts filter """ # Assuming we have 33 sample charts self.login(username="admin") @@ -441,18 +641,62 @@ def test_get_charts_page(self): arguments = {"page_size": 10, "page": 3} uri = f"api/v1/chart/?q={prison.dumps(arguments)}" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get_list") self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(len(data["result"]), 3) def test_get_charts_no_data_access(self): """ - Chart API: Test get charts no data access + Chart API: Test get charts no data access """ self.login(username="gamma") uri = f"api/v1/chart/" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get_list") self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data["count"], 0) + + def test_chart_data(self): + """ + Query API: Test chart data query + """ + self.login(username="admin") + query_context = self._get_query_context() + uri = "api/v1/chart/data" + rv = self.post_assert_metric(uri, query_context, "data") + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["result"][0]["rowcount"], 100) + + def test_invalid_chart_data(self): + """ + Query API: Test chart data query with invalid schema + """ + self.login(username="admin") + query_context = self._get_query_context() + query_context["datasource"] = "abc" + uri = "api/v1/chart/data" + rv = self.client.post(uri, json=query_context) + self.assertEqual(rv.status_code, 400) + + def test_query_exec_not_allowed(self): + """ + Query API: Test chart data query not allowed + """ + self.login(username="gamma") + query_context = self._get_query_context() + uri = "api/v1/chart/data" + rv = self.post_assert_metric(uri, query_context, "data") + self.assertEqual(rv.status_code, 401) + + def test_datasources(self): + """ + Chart API: Test get datasources + """ + self.login(username="admin") + uri = "api/v1/chart/datasources" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["count"], 6) diff --git a/tests/core_tests.py b/tests/core_tests.py index 8b4ce6978eda..04cc5874137b 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -111,7 +111,7 @@ def test_slice_endpoint(self): resp = self.client.get("/superset/slice/-1/") assert resp.status_code == 404 - def _get_query_context_dict(self) -> Dict[str, Any]: + def _get_query_context(self) -> Dict[str, Any]: self.login(username="admin") slc = self.get_slice("Girl Name Cloud", db.session) return { @@ -127,6 +127,45 @@ def _get_query_context_dict(self) -> Dict[str, Any]: ], } + def _get_query_context_with_post_processing(self) -> Dict[str, Any]: + self.login(username="admin") + slc = self.get_slice("Girl Name Cloud", db.session) + return { + "datasource": {"id": slc.datasource_id, "type": slc.datasource_type}, + "queries": [ + { + "granularity": "ds", + "groupby": ["name", "state"], + "metrics": [{"label": "sum__num"}], + "filters": [], + "row_limit": 100, + "post_processing": [ + { + "operation": "aggregate", + "options": { + "groupby": ["state"], + "aggregates": { + "q1": { + "operator": "percentile", + "column": "sum__num", + "options": {"q": 25}, + }, + "median": { + "operator": "median", + "column": "sum__num", + }, + }, + }, + }, + { + "operation": "sort", + "options": {"columns": {"q1": False, "state": True},}, + }, + ], + } + ], + } + def test_viz_cache_key(self): self.login(username="admin") slc = self.get_slice("Girls", db.session) @@ -140,7 +179,7 @@ def test_viz_cache_key(self): self.assertNotEqual(cache_key, viz.cache_key(qobj)) def test_cache_key_changes_when_datasource_is_updated(self): - qc_dict = self._get_query_context_dict() + qc_dict = self._get_query_context() # construct baseline cache_key query_context = QueryContext(**qc_dict) @@ -168,7 +207,7 @@ def test_cache_key_changes_when_datasource_is_updated(self): self.assertNotEqual(cache_key_original, cache_key_new) def test_query_context_time_range_endpoints(self): - query_context = QueryContext(**self._get_query_context_dict()) + query_context = QueryContext(**self._get_query_context()) query_object = query_context.queries[0] extras = query_object.to_dict()["extras"] self.assertTrue("time_range_endpoints" in extras) @@ -217,11 +256,18 @@ def test_get_superset_tables_not_found(self): def test_api_v1_query_endpoint(self): self.login(username="admin") - qc_dict = self._get_query_context_dict() + qc_dict = self._get_query_context() data = json.dumps(qc_dict) resp = json.loads(self.get_resp("/api/v1/query/", {"query_context": data})) self.assertEqual(resp[0]["rowcount"], 100) + def test_api_v1_query_endpoint_with_post_processing(self): + self.login(username="admin") + qc_dict = self._get_query_context_with_post_processing() + data = json.dumps(qc_dict) + resp = json.loads(self.get_resp("/api/v1/query/", {"query_context": data})) + self.assertEqual(resp[0]["rowcount"], 6) + def test_old_slice_json_endpoint(self): self.login(username="admin") slc = self.get_slice("Girls", db.session) @@ -288,9 +334,10 @@ def test_save_slice(self): self.login(username="admin") slice_name = f"Energy Sankey" slice_id = self.get_slice(slice_name, db.session).id - copy_name = f"Test Sankey Save_{random.random()}" + copy_name_prefix = "Test Sankey" + copy_name = f"{copy_name_prefix}[save]{random.random()}" tbl_id = self.table_ids.get("energy_usage") - new_slice_name = f"Test Sankey Overwrite_{random.random()}" + new_slice_name = f"{copy_name_prefix}[overwrite]{random.random()}" url = ( "/superset/explore/table/{}/?slice_name={}&" @@ -298,8 +345,9 @@ def test_save_slice(self): ) form_data = { + "adhoc_filters": [], "viz_type": "sankey", - "groupby": "target", + "groupby": ["target"], "metric": "sum__value", "row_limit": 5000, "slice_id": slice_id, @@ -319,8 +367,9 @@ def test_save_slice(self): self.assertEqual(slc.viz.form_data, form_data) form_data = { + "adhoc_filters": [], "viz_type": "sankey", - "groupby": "source", + "groupby": ["source"], "metric": "sum__value", "row_limit": 5000, "slice_id": new_slice_id, @@ -338,7 +387,13 @@ def test_save_slice(self): self.assertEqual(slc.viz.form_data, form_data) # Cleanup - db.session.delete(slc) + slices = ( + db.session.query(Slice) + .filter(Slice.slice_name.like(copy_name_prefix + "%")) + .all() + ) + for slc in slices: + db.session.delete(slc) db.session.commit() def test_filter_endpoint(self): @@ -555,7 +610,9 @@ def test_databaseview_edit(self, username="admin"): def test_warm_up_cache(self): slc = self.get_slice("Girls", db.session) data = self.get_json_resp("/superset/warm_up_cache?slice_id={}".format(slc.id)) - self.assertEqual(data, [{"slice_id": slc.id, "slice_name": slc.slice_name}]) + self.assertEqual( + data, [{"slice_id": slc.id, "viz_error": None, "viz_status": "success"}] + ) data = self.get_json_resp( "/superset/warm_up_cache?table_name=energy_usage&db_name=main" @@ -659,6 +716,89 @@ def test_templated_sql_json(self): data = self.run_sql(sql, "fdaklj3ws") self.assertEqual(data["data"][0]["test"], "2017-01-01T00:00:00") + @mock.patch("tests.superset_test_custom_template_processors.datetime") + def test_custom_process_template(self, mock_dt) -> None: + """Test macro defined in custom template processor works.""" + mock_dt.utcnow = mock.Mock(return_value=datetime.datetime(1970, 1, 1)) + db = mock.Mock() + db.backend = "presto" + tp = jinja_context.get_template_processor(database=db) + + sql = "SELECT '$DATE()'" + rendered = tp.process_template(sql) + self.assertEqual("SELECT '{}'".format("1970-01-01"), rendered) + + sql = "SELECT '$DATE(1, 2)'" + rendered = tp.process_template(sql) + self.assertEqual("SELECT '{}'".format("1970-01-02"), rendered) + + def test_custom_get_template_kwarg(self): + """Test macro passed as kwargs when getting template processor + works in custom template processor.""" + db = mock.Mock() + db.backend = "presto" + s = "$foo()" + tp = jinja_context.get_template_processor(database=db, foo=lambda: "bar") + rendered = tp.process_template(s) + self.assertEqual("bar", rendered) + + def test_custom_template_kwarg(self) -> None: + """Test macro passed as kwargs when processing template + works in custom template processor.""" + db = mock.Mock() + db.backend = "presto" + s = "$foo()" + tp = jinja_context.get_template_processor(database=db) + rendered = tp.process_template(s, foo=lambda: "bar") + self.assertEqual("bar", rendered) + + def test_custom_template_processors_overwrite(self) -> None: + """Test template processor for presto gets overwritten by custom one.""" + db = mock.Mock() + db.backend = "presto" + tp = jinja_context.get_template_processor(database=db) + + sql = "SELECT '{{ datetime(2017, 1, 1).isoformat() }}'" + rendered = tp.process_template(sql) + self.assertEqual(sql, rendered) + + sql = "SELECT '{{ DATE(1, 2) }}'" + rendered = tp.process_template(sql) + self.assertEqual(sql, rendered) + + def test_custom_template_processors_ignored(self) -> None: + """Test custom template processor is ignored for a difference backend + database.""" + maindb = utils.get_example_database() + sql = "SELECT '$DATE()'" + tp = jinja_context.get_template_processor(database=maindb) + rendered = tp.process_template(sql) + self.assertEqual(sql, rendered) + + @mock.patch("tests.superset_test_custom_template_processors.datetime") + @mock.patch("superset.sql_lab.get_sql_results") + def test_custom_templated_sql_json(self, sql_lab_mock, mock_dt) -> None: + """Test sqllab receives macros expanded query.""" + mock_dt.utcnow = mock.Mock(return_value=datetime.datetime(1970, 1, 1)) + self.login("admin") + sql = "SELECT '$DATE()' as test" + resp = { + "status": utils.QueryStatus.SUCCESS, + "query": {"rows": 1}, + "data": [{"test": "'1970-01-01'"}], + } + sql_lab_mock.return_value = resp + + dbobj = self.create_fake_presto_db() + json_payload = dict(database_id=dbobj.id, sql=sql) + self.get_json_resp( + "/superset/sql_json/", raise_on_error=False, json_=json_payload + ) + assert sql_lab_mock.called + self.assertEqual(sql_lab_mock.call_args[0][1], "SELECT '1970-01-01' as test") + + self.delete_fake_presto_db() + def test_fetch_datasource_metadata(self): self.login(username="admin") url = "/superset/fetch_datasource_metadata?" "datasourceKey=1__table" @@ -722,15 +862,6 @@ def test_slice_id_is_always_logged_correctly_on_ajax_request(self): self.get_json_resp(slc_url, {"form_data": json.dumps(slc.form_data)}) self.assertEqual(1, qry.count()) - def test_slice_query_endpoint(self): - # API endpoint for query string - self.login(username="admin") - slc = self.get_slice("Girls", db.session) - resp = self.get_resp("/superset/slice_query/{}/".format(slc.id)) - assert "query" in resp - assert "language" in resp - self.logout() - def test_import_csv(self): self.login(username="admin") table_name = "".join(random.choice(string.ascii_uppercase) for _ in range(5)) @@ -1168,7 +1299,7 @@ def test_sqllab_backend_persistence_payload(self): # we should have only 1 query returned, since the second one is not # associated with any tabs - payload = views.Superset._get_sqllab_payload(user_id=user_id) + payload = views.Superset._get_sqllab_tabs(user_id=user_id) self.assertEqual(len(payload["queries"]), 1) diff --git a/tests/dashboards/api_tests.py b/tests/dashboards/api_tests.py index 1c8a050f2a77..f8119f55592d 100644 --- a/tests/dashboards/api_tests.py +++ b/tests/dashboards/api_tests.py @@ -50,7 +50,7 @@ def __init__(self, *args, **kwargs): def insert_dashboard( self, dashboard_title: str, - slug: str, + slug: Optional[str], owners: List[int], slices: Optional[List[Slice]] = None, position_json: str = "", @@ -79,13 +79,13 @@ def insert_dashboard( def test_get_dashboard(self): """ - Dashboard API: Test get dashboard + Dashboard API: Test get dashboard """ admin = self.get_user("admin") dashboard = self.insert_dashboard("title", "slug1", [admin.id]) self.login(username="admin") uri = f"api/v1/dashboard/{dashboard.id}" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get") self.assertEqual(rv.status_code, 200) expected_result = { "changed_by": None, @@ -96,12 +96,20 @@ def test_get_dashboard(self): "css": "", "dashboard_title": "title", "json_metadata": "", - "owners": [{"id": 1, "username": "admin"}], + "owners": [ + { + "id": 1, + "username": "admin", + "first_name": "admin", + "last_name": "user", + } + ], "position_json": "", "published": False, "url": f"/superset/dashboard/slug1/", "slug": "slug1", "table_names": "", + "thumbnail_url": dashboard.thumbnail_url, } data = json.loads(rv.data.decode("utf-8")) self.assertIn("changed_on", data["result"]) @@ -113,19 +121,28 @@ def test_get_dashboard(self): db.session.delete(dashboard) db.session.commit() + def test_info_dashboard(self): + """ + Dashboard API: Test info + """ + self.login(username="admin") + uri = f"api/v1/dashboard/_info" + rv = self.get_assert_metric(uri, "info") + self.assertEqual(rv.status_code, 200) + def test_get_dashboard_not_found(self): """ - Dashboard API: Test get dashboard not found + Dashboard API: Test get dashboard not found """ max_id = db.session.query(func.max(Dashboard.id)).scalar() self.login(username="admin") uri = f"api/v1/dashboard/{max_id + 1}" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get") self.assertEqual(rv.status_code, 404) def test_get_dashboard_no_data_access(self): """ - Dashboard API: Test get dashboard without data access + Dashboard API: Test get dashboard without data access """ admin = self.get_user("admin") dashboard = self.insert_dashboard("title", "slug1", [admin.id]) @@ -140,7 +157,7 @@ def test_get_dashboard_no_data_access(self): def test_get_dashboards_filter(self): """ - Dashboard API: Test get dashboards filter + Dashboard API: Test get dashboards filter """ admin = self.get_user("admin") gamma = self.get_user("gamma") @@ -152,7 +169,8 @@ def test_get_dashboards_filter(self): "filters": [{"col": "dashboard_title", "opr": "sw", "value": "ti"}] } uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}" - rv = self.client.get(uri) + + rv = self.get_assert_metric(uri, "get_list") self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data["count"], 1) @@ -172,9 +190,59 @@ def test_get_dashboards_filter(self): db.session.delete(dashboard) db.session.commit() + def test_get_dashboards_custom_filter(self): + """ + Dashboard API: Test get dashboards custom filter + """ + admin = self.get_user("admin") + dashboard1 = self.insert_dashboard("foo", "ZY_bar", [admin.id]) + dashboard2 = self.insert_dashboard("zy_foo", "slug1", [admin.id]) + dashboard3 = self.insert_dashboard("foo", "slug1zy_", [admin.id]) + dashboard4 = self.insert_dashboard("bar", "foo", [admin.id]) + + arguments = { + "filters": [ + {"col": "dashboard_title", "opr": "title_or_slug", "value": "zy_"} + ], + "order_column": "dashboard_title", + "order_direction": "asc", + } + self.login(username="admin") + uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["count"], 3) + + expected_response = [ + {"slug": "ZY_bar", "dashboard_title": "foo",}, + {"slug": "slug1zy_", "dashboard_title": "foo",}, + {"slug": "slug1", "dashboard_title": "zy_foo",}, + ] + for index, item in enumerate(data["result"]): + self.assertEqual(item["slug"], expected_response[index]["slug"]) + self.assertEqual( + item["dashboard_title"], expected_response[index]["dashboard_title"] + ) + + self.logout() + self.login(username="gamma") + uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["count"], 0) + + # rollback changes + db.session.delete(dashboard1) + db.session.delete(dashboard2) + db.session.delete(dashboard3) + db.session.delete(dashboard4) + db.session.commit() + def test_get_dashboards_no_data_access(self): """ - Dashboard API: Test get dashboards no data access + Dashboard API: Test get dashboards no data access """ admin = self.get_user("admin") dashboard = self.insert_dashboard("title", "slug1", [admin.id]) @@ -195,20 +263,20 @@ def test_get_dashboards_no_data_access(self): def test_delete_dashboard(self): """ - Dashboard API: Test delete + Dashboard API: Test delete """ admin_id = self.get_user("admin").id dashboard_id = self.insert_dashboard("title", "slug1", [admin_id]).id self.login(username="admin") uri = f"api/v1/dashboard/{dashboard_id}" - rv = self.client.delete(uri) + rv = self.delete_assert_metric(uri, "delete") self.assertEqual(rv.status_code, 200) model = db.session.query(Dashboard).get(dashboard_id) self.assertEqual(model, None) def test_delete_bulk_dashboards(self): """ - Dashboard API: Test delete bulk + Dashboard API: Test delete bulk """ admin_id = self.get_user("admin").id dashboard_count = 4 @@ -224,7 +292,7 @@ def test_delete_bulk_dashboards(self): self.login(username="admin") argument = dashboard_ids uri = f"api/v1/dashboard/?q={prison.dumps(argument)}" - rv = self.client.delete(uri) + rv = self.delete_assert_metric(uri, "bulk_delete") self.assertEqual(rv.status_code, 200) response = json.loads(rv.data.decode("utf-8")) expected_response = {"message": f"Deleted {dashboard_count} dashboards"} @@ -235,7 +303,7 @@ def test_delete_bulk_dashboards(self): def test_delete_bulk_dashboards_bad_request(self): """ - Dashboard API: Test delete bulk bad request + Dashboard API: Test delete bulk bad request """ dashboard_ids = [1, "a"] self.login(username="admin") @@ -246,7 +314,7 @@ def test_delete_bulk_dashboards_bad_request(self): def test_delete_not_found_dashboard(self): """ - Dashboard API: Test not found delete + Dashboard API: Test not found delete """ self.login(username="admin") dashboard_id = 1000 @@ -256,7 +324,7 @@ def test_delete_not_found_dashboard(self): def test_delete_bulk_dashboards_not_found(self): """ - Dashboard API: Test delete bulk not found + Dashboard API: Test delete bulk not found """ dashboard_ids = [1001, 1002] self.login(username="admin") @@ -267,7 +335,7 @@ def test_delete_bulk_dashboards_not_found(self): def test_delete_dashboard_admin_not_owned(self): """ - Dashboard API: Test admin delete not owned + Dashboard API: Test admin delete not owned """ gamma_id = self.get_user("gamma").id dashboard_id = self.insert_dashboard("title", "slug1", [gamma_id]).id @@ -281,7 +349,7 @@ def test_delete_dashboard_admin_not_owned(self): def test_delete_bulk_dashboard_admin_not_owned(self): """ - Dashboard API: Test admin delete bulk not owned + Dashboard API: Test admin delete bulk not owned """ gamma_id = self.get_user("gamma").id dashboard_count = 4 @@ -310,7 +378,7 @@ def test_delete_bulk_dashboard_admin_not_owned(self): def test_delete_dashboard_not_owned(self): """ - Dashboard API: Test delete try not owned + Dashboard API: Test delete try not owned """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -335,7 +403,7 @@ def test_delete_dashboard_not_owned(self): def test_delete_bulk_dashboard_not_owned(self): """ - Dashboard API: Test delete bulk try not owned + Dashboard API: Test delete bulk try not owned """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -379,7 +447,7 @@ def test_delete_bulk_dashboard_not_owned(self): expected_response = {"message": "Forbidden"} self.assertEqual(response, expected_response) - # nothing is delete in bulk with a list of owned and not owned dashboards + # nothing is deleted in bulk with a list of owned and not owned dashboards arguments = [dashboard.id for dashboard in dashboards] + [owned_dashboard.id] uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}" rv = self.client.delete(uri) @@ -397,7 +465,7 @@ def test_delete_bulk_dashboard_not_owned(self): def test_create_dashboard(self): """ - Dashboard API: Test create dashboard + Dashboard API: Test create dashboard """ admin_id = self.get_user("admin").id dashboard_data = { @@ -411,7 +479,7 @@ def test_create_dashboard(self): } self.login(username="admin") uri = "api/v1/dashboard/" - rv = self.client.post(uri, json=dashboard_data) + rv = self.post_assert_metric(uri, dashboard_data, "post") self.assertEqual(rv.status_code, 201) data = json.loads(rv.data.decode("utf-8")) model = db.session.query(Dashboard).get(data.get("id")) @@ -420,7 +488,7 @@ def test_create_dashboard(self): def test_create_simple_dashboard(self): """ - Dashboard API: Test create simple dashboard + Dashboard API: Test create simple dashboard """ dashboard_data = {"dashboard_title": "title1"} self.login(username="admin") @@ -434,7 +502,7 @@ def test_create_simple_dashboard(self): def test_create_dashboard_empty(self): """ - Dashboard API: Test create empty + Dashboard API: Test create empty """ dashboard_data = {} self.login(username="admin") @@ -458,12 +526,12 @@ def test_create_dashboard_empty(self): def test_create_dashboard_validate_title(self): """ - Dashboard API: Test create dashboard validate title + Dashboard API: Test create dashboard validate title """ dashboard_data = {"dashboard_title": "a" * 600} self.login(username="admin") uri = "api/v1/dashboard/" - rv = self.client.post(uri, json=dashboard_data) + rv = self.post_assert_metric(uri, dashboard_data, "post") self.assertEqual(rv.status_code, 400) response = json.loads(rv.data.decode("utf-8")) expected_response = { @@ -473,7 +541,7 @@ def test_create_dashboard_validate_title(self): def test_create_dashboard_validate_slug(self): """ - Dashboard API: Test create validate slug + Dashboard API: Test create validate slug """ admin_id = self.get_user("admin").id dashboard = self.insert_dashboard("title1", "slug1", [admin_id]) @@ -502,7 +570,7 @@ def test_create_dashboard_validate_slug(self): def test_create_dashboard_validate_owners(self): """ - Dashboard API: Test create validate owners + Dashboard API: Test create validate owners """ dashboard_data = {"dashboard_title": "title1", "owners": [1000]} self.login(username="admin") @@ -515,7 +583,7 @@ def test_create_dashboard_validate_owners(self): def test_create_dashboard_validate_json(self): """ - Dashboard API: Test create validate json + Dashboard API: Test create validate json """ dashboard_data = {"dashboard_title": "title1", "position_json": '{"A:"a"}'} self.login(username="admin") @@ -540,13 +608,13 @@ def test_create_dashboard_validate_json(self): def test_update_dashboard(self): """ - Dashboard API: Test update + Dashboard API: Test update """ admin = self.get_user("admin") dashboard_id = self.insert_dashboard("title1", "slug1", [admin.id]).id self.login(username="admin") uri = f"api/v1/dashboard/{dashboard_id}" - rv = self.client.put(uri, json=self.dashboard_data) + rv = self.put_assert_metric(uri, self.dashboard_data, "put") self.assertEqual(rv.status_code, 200) model = db.session.query(Dashboard).get(dashboard_id) self.assertEqual(model.dashboard_title, self.dashboard_data["dashboard_title"]) @@ -560,9 +628,52 @@ def test_update_dashboard(self): db.session.delete(model) db.session.commit() + def test_update_dashboard_chart_owners(self): + """ + Dashboard API: Test update chart owners + """ + user_alpha1 = self.create_user( + "alpha1", "password", "Alpha", email="alpha1@superset.org" + ) + user_alpha2 = self.create_user( + "alpha2", "password", "Alpha", email="alpha2@superset.org" + ) + admin = self.get_user("admin") + slices = [] + slices.append( + db.session.query(Slice).filter_by(slice_name="Girl Name Cloud").first() + ) + slices.append(db.session.query(Slice).filter_by(slice_name="Trends").first()) + slices.append(db.session.query(Slice).filter_by(slice_name="Boys").first()) + + dashboard = self.insert_dashboard("title1", "slug1", [admin.id], slices=slices,) + self.login(username="admin") + uri = f"api/v1/dashboard/{dashboard.id}" + dashboard_data = {"owners": [user_alpha1.id, user_alpha2.id]} + rv = self.client.put(uri, json=dashboard_data) + self.assertEqual(rv.status_code, 200) + + # verify slices owners include alpha1 and alpha2 users + slices_ids = [slice.id for slice in slices] + # Refetch Slices + slices = db.session.query(Slice).filter(Slice.id.in_(slices_ids)).all() + for slice in slices: + self.assertIn(user_alpha1, slice.owners) + self.assertIn(user_alpha2, slice.owners) + self.assertIn(admin, slice.owners) + # Revert owners on slice + slice.owners = [] + db.session.commit() + + # Rollback changes + db.session.delete(dashboard) + db.session.delete(user_alpha1) + db.session.delete(user_alpha2) + db.session.commit() + def test_update_partial_dashboard(self): """ - Dashboard API: Test update partial + Dashboard API: Test update partial """ admin_id = self.get_user("admin").id dashboard_id = self.insert_dashboard("title1", "slug1", [admin_id]).id @@ -591,7 +702,7 @@ def test_update_partial_dashboard(self): def test_update_dashboard_new_owner(self): """ - Dashboard API: Test update set new owner to current user + Dashboard API: Test update set new owner to current user """ gamma_id = self.get_user("gamma").id admin = self.get_user("admin") @@ -610,7 +721,7 @@ def test_update_dashboard_new_owner(self): def test_update_dashboard_slug_formatting(self): """ - Dashboard API: Test update slug formatting + Dashboard API: Test update slug formatting """ admin_id = self.get_user("admin").id dashboard_id = self.insert_dashboard("title1", "slug1", [admin_id]).id @@ -627,7 +738,7 @@ def test_update_dashboard_slug_formatting(self): def test_update_dashboard_validate_slug(self): """ - Dashboard API: Test update validate slug + Dashboard API: Test update validate slug """ admin_id = self.get_user("admin").id dashboard1 = self.insert_dashboard("title1", "slug-1", [admin_id]) @@ -647,9 +758,22 @@ def test_update_dashboard_validate_slug(self): db.session.delete(dashboard2) db.session.commit() + dashboard1 = self.insert_dashboard("title1", None, [admin_id]) + dashboard2 = self.insert_dashboard("title2", None, [admin_id]) + self.login(username="admin") + # Accept empty slugs and don't validate them has unique + dashboard_data = {"dashboard_title": "title2_changed", "slug": ""} + uri = f"api/v1/dashboard/{dashboard2.id}" + rv = self.client.put(uri, json=dashboard_data) + self.assertEqual(rv.status_code, 200) + + db.session.delete(dashboard1) + db.session.delete(dashboard2) + db.session.commit() + def test_update_published(self): """ - Dashboard API: Test update published patch + Dashboard API: Test update published patch """ admin = self.get_user("admin") gamma = self.get_user("gamma") @@ -671,7 +795,7 @@ def test_update_published(self): def test_update_dashboard_not_owned(self): """ - Dashboard API: Test update dashboard not owned + Dashboard API: Test update dashboard not owned """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -688,7 +812,7 @@ def test_update_dashboard_not_owned(self): self.login(username="alpha2", password="password") dashboard_data = {"dashboard_title": "title1_changed", "slug": "slug1 changed"} uri = f"api/v1/dashboard/{dashboard.id}" - rv = self.client.put(uri, json=dashboard_data) + rv = self.put_assert_metric(uri, dashboard_data, "put") self.assertEqual(rv.status_code, 403) db.session.delete(dashboard) db.session.delete(user_alpha1) @@ -697,13 +821,12 @@ def test_update_dashboard_not_owned(self): def test_export(self): """ - Dashboard API: Test dashboard export + Dashboard API: Test dashboard export """ self.login(username="admin") argument = [1, 2] uri = f"api/v1/dashboard/export/?q={prison.dumps(argument)}" - - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "export") self.assertEqual(rv.status_code, 200) self.assertEqual( rv.headers["Content-Disposition"], @@ -712,7 +835,7 @@ def test_export(self): def test_export_not_found(self): """ - Dashboard API: Test dashboard export not found + Dashboard API: Test dashboard export not found """ self.login(username="admin") argument = [1000] @@ -722,7 +845,7 @@ def test_export_not_found(self): def test_export_not_allowed(self): """ - Dashboard API: Test dashboard export not allowed + Dashboard API: Test dashboard export not allowed """ admin_id = self.get_user("admin").id dashboard = self.insert_dashboard("title", "slug1", [admin_id], published=False) diff --git a/tests/datasets/__init__.py b/tests/datasets/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/tests/datasets/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/dataset_api_tests.py b/tests/datasets/api_tests.py similarity index 52% rename from tests/dataset_api_tests.py rename to tests/datasets/api_tests.py index a55140a5dae7..c1c3ddf5aa70 100644 --- a/tests/dataset_api_tests.py +++ b/tests/datasets/api_tests.py @@ -20,18 +20,21 @@ from unittest.mock import patch import prison +import yaml +from sqlalchemy.sql import func -from superset import db, security_manager -from superset.connectors.sqla.models import SqlaTable +from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn from superset.dao.exceptions import ( DAOCreateFailedError, DAODeleteFailedError, DAOUpdateFailedError, ) +from superset.extensions import db, security_manager from superset.models.core import Database from superset.utils.core import get_example_database - -from .base_tests import SupersetTestCase +from superset.utils.dict_import_export import export_to_dict +from superset.views.base import generate_download_headers +from tests.base_tests import SupersetTestCase class DatasetApiTests(SupersetTestCase): @@ -48,8 +51,23 @@ def insert_dataset( ) db.session.add(table) db.session.commit() + table.fetch_metadata() return table + def insert_default_dataset(self): + return self.insert_dataset( + "ab_permission", "", [self.get_user("admin").id], get_example_database() + ) + + @staticmethod + def get_birth_names_dataset(): + example_db = get_example_database() + return ( + db.session.query(SqlaTable) + .filter_by(database=example_db, table_name="birth_names") + .one() + ) + def test_get_dataset_list(self): """ Dataset API: Test get dataset list @@ -109,12 +127,7 @@ def test_get_dataset_item(self): """ Dataset API: Test get dataset item """ - example_db = get_example_database() - table = ( - db.session.query(SqlaTable) - .filter_by(database=example_db, table_name="birth_names") - .one() - ) + table = self.get_birth_names_dataset() self.login(username="admin") uri = f"api/v1/dataset/{table.id}" rv = self.client.get(uri) @@ -136,7 +149,10 @@ def test_get_dataset_item(self): "table_name": "birth_names", "template_params": None, } - self.assertEqual(response["result"], expected_result) + for key, value in expected_result.items(): + self.assertEqual(response["result"][key], expected_result[key]) + self.assertEqual(len(response["result"]["columns"]), 8) + self.assertEqual(len(response["result"]["metrics"]), 2) def test_get_dataset_info(self): """ @@ -162,9 +178,30 @@ def test_create_dataset_item(self): rv = self.client.post(uri, json=table_data) self.assertEqual(rv.status_code, 201) data = json.loads(rv.data.decode("utf-8")) - model = db.session.query(SqlaTable).get(data.get("id")) + table_id = data.get("id") + model = db.session.query(SqlaTable).get(table_id) self.assertEqual(model.table_name, table_data["table_name"]) self.assertEqual(model.database_id, table_data["database"]) + + # Assert that columns were created + columns = ( + db.session.query(TableColumn) + .filter_by(table_id=table_id) + .order_by("column_name") + .all() + ) + self.assertEqual(columns[0].column_name, "id") + self.assertEqual(columns[1].column_name, "name") + + # Assert that metrics were created + columns = ( + db.session.query(SqlMetric) + .filter_by(table_id=table_id) + .order_by("metric_name") + .all() + ) + self.assertEqual(columns[0].expression, "COUNT(*)") + db.session.delete(model) db.session.commit() @@ -252,9 +289,9 @@ def test_create_dataset_validate_database(self): Dataset API: Test create dataset validate database exists """ self.login(username="admin") - table_data = {"database": 1000, "schema": "", "table_name": "birth_names"} + dataset_data = {"database": 1000, "schema": "", "table_name": "birth_names"} uri = "api/v1/dataset/" - rv = self.client.post(uri, json=table_data) + rv = self.client.post(uri, json=dataset_data) self.assertEqual(rv.status_code, 422) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data, {"message": {"database": ["Database does not exist"]}}) @@ -297,73 +334,224 @@ def test_update_dataset_item(self): """ Dataset API: Test update dataset item """ - table = self.insert_dataset("ab_permission", "", [], get_example_database()) + dataset = self.insert_default_dataset() self.login(username="admin") - table_data = {"description": "changed_description"} - uri = f"api/v1/dataset/{table.id}" - rv = self.client.put(uri, json=table_data) + dataset_data = {"description": "changed_description"} + uri = f"api/v1/dataset/{dataset.id}" + rv = self.client.put(uri, json=dataset_data) + self.assertEqual(rv.status_code, 200) + model = db.session.query(SqlaTable).get(dataset.id) + self.assertEqual(model.description, dataset_data["description"]) + db.session.delete(dataset) + db.session.commit() + + def test_update_dataset_create_column(self): + """ + Dataset API: Test update dataset create column + """ + # create example dataset by Command + dataset = self.insert_default_dataset() + + new_column_data = { + "column_name": "new_col", + "description": "description", + "expression": "expression", + "type": "INTEGER", + "verbose_name": "New Col", + } + uri = f"api/v1/dataset/{dataset.id}" + # Get current cols and append the new column + self.login(username="admin") + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + data["result"]["columns"].append(new_column_data) + rv = self.client.put(uri, json={"columns": data["result"]["columns"]}) + + self.assertEqual(rv.status_code, 200) + + columns = ( + db.session.query(TableColumn) + .filter_by(table_id=dataset.id) + .order_by("column_name") + .all() + ) + self.assertEqual(columns[0].column_name, "id") + self.assertEqual(columns[1].column_name, "name") + self.assertEqual(columns[2].column_name, new_column_data["column_name"]) + self.assertEqual(columns[2].description, new_column_data["description"]) + self.assertEqual(columns[2].expression, new_column_data["expression"]) + self.assertEqual(columns[2].type, new_column_data["type"]) + self.assertEqual(columns[2].verbose_name, new_column_data["verbose_name"]) + + db.session.delete(dataset) + db.session.commit() + + def test_update_dataset_update_column(self): + """ + Dataset API: Test update dataset columns + """ + dataset = self.insert_default_dataset() + + self.login(username="admin") + uri = f"api/v1/dataset/{dataset.id}" + # Get current cols and alter one + rv = self.client.get(uri) + resp_columns = json.loads(rv.data.decode("utf-8"))["result"]["columns"] + resp_columns[0]["groupby"] = False + resp_columns[0]["filterable"] = False + v = self.client.put(uri, json={"columns": resp_columns}) self.assertEqual(rv.status_code, 200) - model = db.session.query(SqlaTable).get(table.id) - self.assertEqual(model.description, table_data["description"]) - db.session.delete(table) + columns = ( + db.session.query(TableColumn) + .filter_by(table_id=dataset.id) + .order_by("column_name") + .all() + ) + self.assertEqual(columns[0].column_name, "id") + self.assertEqual(columns[1].column_name, "name") + self.assertEqual(columns[0].groupby, False) + self.assertEqual(columns[0].filterable, False) + + db.session.delete(dataset) + db.session.commit() + + def test_update_dataset_update_column_uniqueness(self): + """ + Dataset API: Test update dataset columns uniqueness + """ + dataset = self.insert_default_dataset() + + self.login(username="admin") + uri = f"api/v1/dataset/{dataset.id}" + # try to insert a new column ID that already exists + data = {"columns": [{"column_name": "id", "type": "INTEGER"}]} + rv = self.client.put(uri, json=data) + self.assertEqual(rv.status_code, 422) + data = json.loads(rv.data.decode("utf-8")) + expected_result = { + "message": {"columns": ["One or more columns already exist"]} + } + self.assertEqual(data, expected_result) + db.session.delete(dataset) + db.session.commit() + + def test_update_dataset_update_metric_uniqueness(self): + """ + Dataset API: Test update dataset metric uniqueness + """ + dataset = self.insert_default_dataset() + + self.login(username="admin") + uri = f"api/v1/dataset/{dataset.id}" + # try to insert a new column ID that already exists + data = {"metrics": [{"metric_name": "count", "expression": "COUNT(*)"}]} + rv = self.client.put(uri, json=data) + self.assertEqual(rv.status_code, 422) + data = json.loads(rv.data.decode("utf-8")) + expected_result = { + "message": {"metrics": ["One or more metrics already exist"]} + } + self.assertEqual(data, expected_result) + db.session.delete(dataset) + db.session.commit() + + def test_update_dataset_update_column_duplicate(self): + """ + Dataset API: Test update dataset columns duplicate + """ + dataset = self.insert_default_dataset() + + self.login(username="admin") + uri = f"api/v1/dataset/{dataset.id}" + # try to insert a new column ID that already exists + data = { + "columns": [ + {"column_name": "id", "type": "INTEGER"}, + {"column_name": "id", "type": "VARCHAR"}, + ] + } + rv = self.client.put(uri, json=data) + self.assertEqual(rv.status_code, 422) + data = json.loads(rv.data.decode("utf-8")) + expected_result = { + "message": {"columns": ["One or more columns are duplicated"]} + } + self.assertEqual(data, expected_result) + db.session.delete(dataset) + db.session.commit() + + def test_update_dataset_update_metric_duplicate(self): + """ + Dataset API: Test update dataset metric duplicate + """ + dataset = self.insert_default_dataset() + + self.login(username="admin") + uri = f"api/v1/dataset/{dataset.id}" + # try to insert a new column ID that already exists + data = { + "metrics": [ + {"metric_name": "dup", "expression": "COUNT(*)"}, + {"metric_name": "dup", "expression": "DIFF_COUNT(*)"}, + ] + } + rv = self.client.put(uri, json=data) + self.assertEqual(rv.status_code, 422) + data = json.loads(rv.data.decode("utf-8")) + expected_result = { + "message": {"metrics": ["One or more metrics are duplicated"]} + } + self.assertEqual(data, expected_result) + db.session.delete(dataset) db.session.commit() def test_update_dataset_item_gamma(self): """ Dataset API: Test update dataset item gamma """ - table = self.insert_dataset("ab_permission", "", [], get_example_database()) + dataset = self.insert_default_dataset() self.login(username="gamma") table_data = {"description": "changed_description"} - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.put(uri, json=table_data) self.assertEqual(rv.status_code, 401) - db.session.delete(table) + db.session.delete(dataset) db.session.commit() def test_update_dataset_item_not_owned(self): """ Dataset API: Test update dataset item not owned """ - admin = self.get_user("admin") - table = self.insert_dataset( - "ab_permission", "", [admin.id], get_example_database() - ) + dataset = self.insert_default_dataset() self.login(username="alpha") table_data = {"description": "changed_description"} - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.put(uri, json=table_data) self.assertEqual(rv.status_code, 403) - db.session.delete(table) + db.session.delete(dataset) db.session.commit() def test_update_dataset_item_owners_invalid(self): """ Dataset API: Test update dataset item owner invalid """ - admin = self.get_user("admin") - table = self.insert_dataset( - "ab_permission", "", [admin.id], get_example_database() - ) + dataset = self.insert_default_dataset() self.login(username="admin") table_data = {"description": "changed_description", "owners": [1000]} - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.put(uri, json=table_data) self.assertEqual(rv.status_code, 422) - db.session.delete(table) + db.session.delete(dataset) db.session.commit() def test_update_dataset_item_uniqueness(self): """ Dataset API: Test update dataset uniqueness """ - admin = self.get_user("admin") - table = self.insert_dataset( - "ab_permission", "", [admin.id], get_example_database() - ) + dataset = self.insert_default_dataset() self.login(username="admin") table_data = {"table_name": "birth_names"} - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.put(uri, json=table_data) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(rv.status_code, 422) @@ -371,7 +559,7 @@ def test_update_dataset_item_uniqueness(self): "message": {"table_name": ["Datasource birth_names already exists"]} } self.assertEqual(data, expected_response) - db.session.delete(table) + db.session.delete(dataset) db.session.commit() @patch("superset.datasets.dao.DatasetDAO.update") @@ -381,25 +569,25 @@ def test_update_dataset_sqlalchemy_error(self, mock_dao_update): """ mock_dao_update.side_effect = DAOUpdateFailedError() - table = self.insert_dataset("ab_permission", "", [], get_example_database()) + dataset = self.insert_default_dataset() self.login(username="admin") table_data = {"description": "changed_description"} - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.put(uri, json=table_data) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(rv.status_code, 422) self.assertEqual(data, {"message": "Dataset could not be updated."}) + db.session.delete(dataset) + db.session.commit() + def test_delete_dataset_item(self): """ Dataset API: Test delete dataset item """ - admin = self.get_user("admin") - table = self.insert_dataset( - "ab_permission", "", [admin.id], get_example_database() - ) + dataset = self.insert_default_dataset() self.login(username="admin") - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.delete(uri) self.assertEqual(rv.status_code, 200) @@ -407,30 +595,24 @@ def test_delete_item_dataset_not_owned(self): """ Dataset API: Test delete item not owned """ - admin = self.get_user("admin") - table = self.insert_dataset( - "ab_permission", "", [admin.id], get_example_database() - ) + dataset = self.insert_default_dataset() self.login(username="alpha") - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.delete(uri) self.assertEqual(rv.status_code, 403) - db.session.delete(table) + db.session.delete(dataset) db.session.commit() def test_delete_dataset_item_not_authorized(self): """ Dataset API: Test delete item not authorized """ - admin = self.get_user("admin") - table = self.insert_dataset( - "ab_permission", "", [admin.id], get_example_database() - ) + dataset = self.insert_default_dataset() self.login(username="gamma") - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.delete(uri) self.assertEqual(rv.status_code, 401) - db.session.delete(table) + db.session.delete(dataset) db.session.commit() @patch("superset.datasets.dao.DatasetDAO.delete") @@ -440,15 +622,124 @@ def test_delete_dataset_sqlalchemy_error(self, mock_dao_delete): """ mock_dao_delete.side_effect = DAODeleteFailedError() - admin = self.get_user("admin") - table = self.insert_dataset( - "ab_permission", "", [admin.id], get_example_database() - ) + dataset = self.insert_default_dataset() self.login(username="admin") - uri = f"api/v1/dataset/{table.id}" + uri = f"api/v1/dataset/{dataset.id}" rv = self.client.delete(uri) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(rv.status_code, 422) self.assertEqual(data, {"message": "Dataset could not be deleted."}) - db.session.delete(table) + db.session.delete(dataset) + db.session.commit() + + def test_dataset_item_refresh(self): + """ + Dataset API: Test item refresh + """ + dataset = self.insert_default_dataset() + # delete a column + id_column = ( + db.session.query(TableColumn) + .filter_by(table_id=dataset.id, column_name="id") + .one() + ) + db.session.delete(id_column) db.session.commit() + + self.login(username="admin") + uri = f"api/v1/dataset/{dataset.id}/refresh" + rv = self.client.put(uri) + self.assertEqual(rv.status_code, 200) + # Assert the column is restored on refresh + id_column = ( + db.session.query(TableColumn) + .filter_by(table_id=dataset.id, column_name="id") + .one() + ) + self.assertIsNotNone(id_column) + db.session.delete(dataset) + db.session.commit() + + def test_dataset_item_refresh_not_found(self): + """ + Dataset API: Test item refresh not found dataset + """ + max_id = db.session.query(func.max(SqlaTable.id)).scalar() + + self.login(username="admin") + uri = f"api/v1/dataset/{max_id + 1}/refresh" + rv = self.client.put(uri) + self.assertEqual(rv.status_code, 404) + + def test_dataset_item_refresh_not_owned(self): + """ + Dataset API: Test item refresh not owned dataset + """ + dataset = self.insert_default_dataset() + self.login(username="alpha") + uri = f"api/v1/dataset/{dataset.id}/refresh" + rv = self.client.put(uri) + self.assertEqual(rv.status_code, 403) + + db.session.delete(dataset) + db.session.commit() + + def test_export_dataset(self): + """ + Dataset API: Test export dataset + :return: + """ + birth_names_dataset = self.get_birth_names_dataset() + + argument = [birth_names_dataset.id] + uri = f"api/v1/dataset/export/?q={prison.dumps(argument)}" + + self.login(username="admin") + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + self.assertEqual( + rv.headers["Content-Disposition"], + generate_download_headers("yaml")["Content-Disposition"], + ) + + cli_export = export_to_dict( + session=db.session, + recursive=True, + back_references=False, + include_defaults=False, + ) + cli_export_tables = cli_export["databases"][0]["tables"] + expected_response = [] + for export_table in cli_export_tables: + if export_table["table_name"] == "birth_names": + expected_response = export_table + break + ui_export = yaml.safe_load(rv.data.decode("utf-8")) + self.assertEqual(ui_export[0], expected_response) + + def test_export_dataset_not_found(self): + """ + Dataset API: Test export dataset not found + :return: + """ + max_id = db.session.query(func.max(SqlaTable.id)).scalar() + # Just one does not exist and we get 404 + argument = [max_id + 1, 1] + uri = f"api/v1/dataset/export/?q={prison.dumps(argument)}" + self.login(username="admin") + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + + def test_export_dataset_gamma(self): + """ + Dataset API: Test export dataset has gamma + :return: + """ + birth_names_dataset = self.get_birth_names_dataset() + + argument = [birth_names_dataset.id] + uri = f"api/v1/dataset/export/?q={prison.dumps(argument)}" + + self.login(username="gamma") + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 401) diff --git a/tests/db_engine_specs/hive_tests.py b/tests/db_engine_specs/hive_tests.py index 4d24c0bdf25a..15de3ce6693d 100644 --- a/tests/db_engine_specs/hive_tests.py +++ b/tests/db_engine_specs/hive_tests.py @@ -58,7 +58,7 @@ def test_job_1_launched_stage_1(self): self.assertEqual(0, HiveEngineSpec.progress(log)) def test_job_1_launched_stage_1_map_40_progress( - self + self, ): # pylint: disable=invalid-name log = """ 17/02/07 19:15:55 INFO ql.Driver: Total jobs = 2 @@ -71,7 +71,7 @@ def test_job_1_launched_stage_1_map_40_progress( self.assertEqual(10, HiveEngineSpec.progress(log)) def test_job_1_launched_stage_1_map_80_reduce_40_progress( - self + self, ): # pylint: disable=invalid-name log = """ 17/02/07 19:15:55 INFO ql.Driver: Total jobs = 2 @@ -85,7 +85,7 @@ def test_job_1_launched_stage_1_map_80_reduce_40_progress( self.assertEqual(30, HiveEngineSpec.progress(log)) def test_job_1_launched_stage_2_stages_progress( - self + self, ): # pylint: disable=invalid-name log = """ 17/02/07 19:15:55 INFO ql.Driver: Total jobs = 2 @@ -101,7 +101,7 @@ def test_job_1_launched_stage_2_stages_progress( self.assertEqual(12, HiveEngineSpec.progress(log)) def test_job_2_launched_stage_2_stages_progress( - self + self, ): # pylint: disable=invalid-name log = """ 17/02/07 19:15:55 INFO ql.Driver: Total jobs = 2 @@ -145,7 +145,7 @@ def test_hive_error_msg(self): ) def test_hive_get_view_names_return_empty_list( - self + self, ): # pylint: disable=invalid-name self.assertEqual( [], HiveEngineSpec.get_view_names(mock.ANY, mock.ANY, mock.ANY) diff --git a/tests/db_engine_specs/presto_tests.py b/tests/db_engine_specs/presto_tests.py index cf62b282d4a4..0ca06359a046 100644 --- a/tests/db_engine_specs/presto_tests.py +++ b/tests/db_engine_specs/presto_tests.py @@ -32,7 +32,7 @@ def test_get_datatype_presto(self): self.assertEqual("STRING", PrestoEngineSpec.get_datatype("string")) def test_presto_get_view_names_return_empty_list( - self + self, ): # pylint: disable=invalid-name self.assertEqual( [], PrestoEngineSpec.get_view_names(mock.ANY, mock.ANY, mock.ANY) diff --git a/tests/druid_func_tests.py b/tests/druid_func_tests.py index 1ed7057bc791..699afeaec835 100644 --- a/tests/druid_func_tests.py +++ b/tests/druid_func_tests.py @@ -415,11 +415,11 @@ def test_run_query_no_groupby(self): client.query_builder.last_query.query_dict = {"mock": 0} # no groupby calls client.timeseries ds.run_query( - groupby, metrics, None, from_dttm, to_dttm, + groupby=groupby, client=client, filter=[], row_limit=100, @@ -472,11 +472,11 @@ def test_run_query_with_adhoc_metric(self): client.query_builder.last_query.query_dict = {"mock": 0} # no groupby calls client.timeseries ds.run_query( - groupby, metrics, None, from_dttm, to_dttm, + groupby=groupby, client=client, filter=[], row_limit=100, @@ -519,11 +519,11 @@ def test_run_query_single_groupby(self): client.query_builder.last_query.query_dict = {"mock": 0} # client.topn is called twice ds.run_query( - groupby, metrics, None, from_dttm, to_dttm, + groupby=groupby, timeseries_limit=100, client=client, order_desc=True, @@ -543,11 +543,11 @@ def test_run_query_single_groupby(self): client = Mock() client.query_builder.last_query.query_dict = {"mock": 0} ds.run_query( - groupby, metrics, None, from_dttm, to_dttm, + groupby=groupby, client=client, order_desc=False, filter=[], @@ -568,11 +568,11 @@ def test_run_query_single_groupby(self): client = Mock() client.query_builder.last_query.query_dict = {"mock": 0} ds.run_query( - groupby, metrics, None, from_dttm, to_dttm, + groupby=groupby, client=client, order_desc=True, timeseries_limit=5, @@ -619,11 +619,11 @@ def test_run_query_multiple_groupby(self): client.query_builder.last_query.query_dict = {"mock": 0} # no groupby calls client.timeseries ds.run_query( - groupby, metrics, None, from_dttm, to_dttm, + groupby=groupby, client=client, row_limit=100, filter=[], @@ -1021,11 +1021,11 @@ def test_run_query_order_by_metrics(self): granularity = "all" # get the counts of the top 5 'dim1's, order by 'sum1' ds.run_query( - groupby, metrics, granularity, from_dttm, to_dttm, + groupby=groupby, timeseries_limit=5, timeseries_limit_metric="sum1", client=client, @@ -1042,11 +1042,11 @@ def test_run_query_order_by_metrics(self): # get the counts of the top 5 'dim1's, order by 'div1' ds.run_query( - groupby, metrics, granularity, from_dttm, to_dttm, + groupby=groupby, timeseries_limit=5, timeseries_limit_metric="div1", client=client, @@ -1064,11 +1064,11 @@ def test_run_query_order_by_metrics(self): groupby = ["dim1", "dim2"] # get the counts of the top 5 ['dim1', 'dim2']s, order by 'sum1' ds.run_query( - groupby, metrics, granularity, from_dttm, to_dttm, + groupby=groupby, timeseries_limit=5, timeseries_limit_metric="sum1", client=client, @@ -1085,11 +1085,11 @@ def test_run_query_order_by_metrics(self): # get the counts of the top 5 ['dim1', 'dim2']s, order by 'div1' ds.run_query( - groupby, metrics, granularity, from_dttm, to_dttm, + groupby=groupby, timeseries_limit=5, timeseries_limit_metric="div1", client=client, diff --git a/tests/druid_func_tests_sip38.py b/tests/druid_func_tests_sip38.py new file mode 100644 index 000000000000..058d8c1743bd --- /dev/null +++ b/tests/druid_func_tests_sip38.py @@ -0,0 +1,1157 @@ +# 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. +# isort:skip_file +import json +import unittest +from unittest.mock import Mock, patch + +import tests.test_app +import superset.connectors.druid.models as models +from superset.connectors.druid.models import DruidColumn, DruidDatasource, DruidMetric +from superset.exceptions import SupersetException + +from .base_tests import SupersetTestCase + +try: + from pydruid.utils.dimensions import ( + MapLookupExtraction, + RegexExtraction, + RegisteredLookupExtraction, + TimeFormatExtraction, + ) + import pydruid.utils.postaggregator as postaggs +except ImportError: + pass + + +def mock_metric(metric_name, is_postagg=False): + metric = Mock() + metric.metric_name = metric_name + metric.metric_type = "postagg" if is_postagg else "metric" + return metric + + +def emplace(metrics_dict, metric_name, is_postagg=False): + metrics_dict[metric_name] = mock_metric(metric_name, is_postagg) + + +# Unit tests that can be run without initializing base tests +@patch.dict( + "superset.extensions.feature_flag_manager._feature_flags", + {"SIP_38_VIZ_REARCHITECTURE": True}, + clear=True, +) +class DruidFuncTestCase(SupersetTestCase): + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_extraction_fn_map(self): + filters = [{"col": "deviceName", "val": ["iPhone X"], "op": "in"}] + dimension_spec = { + "type": "extraction", + "dimension": "device", + "outputName": "deviceName", + "outputType": "STRING", + "extractionFn": { + "type": "lookup", + "dimension": "dimensionName", + "outputName": "dimensionOutputName", + "replaceMissingValueWith": "missing_value", + "retainMissingValue": False, + "lookup": { + "type": "map", + "map": { + "iPhone10,1": "iPhone 8", + "iPhone10,4": "iPhone 8", + "iPhone10,2": "iPhone 8 Plus", + "iPhone10,5": "iPhone 8 Plus", + "iPhone10,3": "iPhone X", + "iPhone10,6": "iPhone X", + }, + "isOneToOne": False, + }, + }, + } + spec_json = json.dumps(dimension_spec) + col = DruidColumn(column_name="deviceName", dimension_spec_json=spec_json) + column_dict = {"deviceName": col} + f = DruidDatasource.get_filters(filters, [], column_dict) + assert isinstance(f.extraction_function, MapLookupExtraction) + dim_ext_fn = dimension_spec["extractionFn"] + f_ext_fn = f.extraction_function + self.assertEqual(dim_ext_fn["lookup"]["map"], f_ext_fn._mapping) + self.assertEqual(dim_ext_fn["lookup"]["isOneToOne"], f_ext_fn._injective) + self.assertEqual( + dim_ext_fn["replaceMissingValueWith"], f_ext_fn._replace_missing_values + ) + self.assertEqual( + dim_ext_fn["retainMissingValue"], f_ext_fn._retain_missing_values + ) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_extraction_fn_regex(self): + filters = [{"col": "buildPrefix", "val": ["22B"], "op": "in"}] + dimension_spec = { + "type": "extraction", + "dimension": "build", + "outputName": "buildPrefix", + "outputType": "STRING", + "extractionFn": {"type": "regex", "expr": "(^[0-9A-Za-z]{3})"}, + } + spec_json = json.dumps(dimension_spec) + col = DruidColumn(column_name="buildPrefix", dimension_spec_json=spec_json) + column_dict = {"buildPrefix": col} + f = DruidDatasource.get_filters(filters, [], column_dict) + assert isinstance(f.extraction_function, RegexExtraction) + dim_ext_fn = dimension_spec["extractionFn"] + f_ext_fn = f.extraction_function + self.assertEqual(dim_ext_fn["expr"], f_ext_fn._expr) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_extraction_fn_registered_lookup_extraction(self): + filters = [{"col": "country", "val": ["Spain"], "op": "in"}] + dimension_spec = { + "type": "extraction", + "dimension": "country_name", + "outputName": "country", + "outputType": "STRING", + "extractionFn": {"type": "registeredLookup", "lookup": "country_name"}, + } + spec_json = json.dumps(dimension_spec) + col = DruidColumn(column_name="country", dimension_spec_json=spec_json) + column_dict = {"country": col} + f = DruidDatasource.get_filters(filters, [], column_dict) + assert isinstance(f.extraction_function, RegisteredLookupExtraction) + dim_ext_fn = dimension_spec["extractionFn"] + self.assertEqual(dim_ext_fn["type"], f.extraction_function.extraction_type) + self.assertEqual(dim_ext_fn["lookup"], f.extraction_function._lookup) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_extraction_fn_time_format(self): + filters = [{"col": "dayOfMonth", "val": ["1", "20"], "op": "in"}] + dimension_spec = { + "type": "extraction", + "dimension": "__time", + "outputName": "dayOfMonth", + "extractionFn": { + "type": "timeFormat", + "format": "d", + "timeZone": "Asia/Kolkata", + "locale": "en", + }, + } + spec_json = json.dumps(dimension_spec) + col = DruidColumn(column_name="dayOfMonth", dimension_spec_json=spec_json) + column_dict = {"dayOfMonth": col} + f = DruidDatasource.get_filters(filters, [], column_dict) + assert isinstance(f.extraction_function, TimeFormatExtraction) + dim_ext_fn = dimension_spec["extractionFn"] + self.assertEqual(dim_ext_fn["type"], f.extraction_function.extraction_type) + self.assertEqual(dim_ext_fn["format"], f.extraction_function._format) + self.assertEqual(dim_ext_fn["timeZone"], f.extraction_function._time_zone) + self.assertEqual(dim_ext_fn["locale"], f.extraction_function._locale) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_ignores_invalid_filter_objects(self): + filtr = {"col": "col1", "op": "=="} + filters = [filtr] + col = DruidColumn(column_name="col1") + column_dict = {"col1": col} + self.assertIsNone(DruidDatasource.get_filters(filters, [], column_dict)) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_constructs_filter_in(self): + filtr = {"col": "A", "op": "in", "val": ["a", "b", "c"]} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertIn("filter", res.filter) + self.assertIn("fields", res.filter["filter"]) + self.assertEqual("or", res.filter["filter"]["type"]) + self.assertEqual(3, len(res.filter["filter"]["fields"])) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_constructs_filter_not_in(self): + filtr = {"col": "A", "op": "not in", "val": ["a", "b", "c"]} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertIn("filter", res.filter) + self.assertIn("type", res.filter["filter"]) + self.assertEqual("not", res.filter["filter"]["type"]) + self.assertIn("field", res.filter["filter"]) + self.assertEqual( + 3, len(res.filter["filter"]["field"].filter["filter"]["fields"]) + ) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_constructs_filter_equals(self): + filtr = {"col": "A", "op": "==", "val": "h"} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("selector", res.filter["filter"]["type"]) + self.assertEqual("A", res.filter["filter"]["dimension"]) + self.assertEqual("h", res.filter["filter"]["value"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_constructs_filter_not_equals(self): + filtr = {"col": "A", "op": "!=", "val": "h"} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("not", res.filter["filter"]["type"]) + self.assertEqual("h", res.filter["filter"]["field"].filter["filter"]["value"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_constructs_bounds_filter(self): + filtr = {"col": "A", "op": ">=", "val": "h"} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertFalse(res.filter["filter"]["lowerStrict"]) + self.assertEqual("A", res.filter["filter"]["dimension"]) + self.assertEqual("h", res.filter["filter"]["lower"]) + self.assertEqual("lexicographic", res.filter["filter"]["ordering"]) + filtr["op"] = ">" + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertTrue(res.filter["filter"]["lowerStrict"]) + filtr["op"] = "<=" + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertFalse(res.filter["filter"]["upperStrict"]) + self.assertEqual("h", res.filter["filter"]["upper"]) + filtr["op"] = "<" + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertTrue(res.filter["filter"]["upperStrict"]) + filtr["val"] = 1 + res = DruidDatasource.get_filters([filtr], ["A"], column_dict) + self.assertEqual("numeric", res.filter["filter"]["ordering"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_is_null_filter(self): + filtr = {"col": "A", "op": "IS NULL"} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("selector", res.filter["filter"]["type"]) + self.assertEqual("", res.filter["filter"]["value"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_is_not_null_filter(self): + filtr = {"col": "A", "op": "IS NOT NULL"} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("not", res.filter["filter"]["type"]) + self.assertIn("field", res.filter["filter"]) + self.assertEqual( + "selector", res.filter["filter"]["field"].filter["filter"]["type"] + ) + self.assertEqual("", res.filter["filter"]["field"].filter["filter"]["value"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_constructs_regex_filter(self): + filtr = {"col": "A", "op": "regex", "val": "[abc]"} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("regex", res.filter["filter"]["type"]) + self.assertEqual("[abc]", res.filter["filter"]["pattern"]) + self.assertEqual("A", res.filter["filter"]["dimension"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_composes_multiple_filters(self): + filtr1 = {"col": "A", "op": "!=", "val": "y"} + filtr2 = {"col": "B", "op": "in", "val": ["a", "b", "c"]} + cola = DruidColumn(column_name="A") + colb = DruidColumn(column_name="B") + column_dict = {"A": cola, "B": colb} + res = DruidDatasource.get_filters([filtr1, filtr2], [], column_dict) + self.assertEqual("and", res.filter["filter"]["type"]) + self.assertEqual(2, len(res.filter["filter"]["fields"])) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_ignores_in_not_in_with_empty_value(self): + filtr1 = {"col": "A", "op": "in", "val": []} + filtr2 = {"col": "A", "op": "not in", "val": []} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr1, filtr2], [], column_dict) + self.assertIsNone(res) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_constructs_equals_for_in_not_in_single_value(self): + filtr = {"col": "A", "op": "in", "val": ["a"]} + cola = DruidColumn(column_name="A") + colb = DruidColumn(column_name="B") + column_dict = {"A": cola, "B": colb} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("selector", res.filter["filter"]["type"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_handles_arrays_for_string_types(self): + filtr = {"col": "A", "op": "==", "val": ["a", "b"]} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("a", res.filter["filter"]["value"]) + + filtr = {"col": "A", "op": "==", "val": []} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertIsNone(res.filter["filter"]["value"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_handles_none_for_string_types(self): + filtr = {"col": "A", "op": "==", "val": None} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertIsNone(res) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_extracts_values_in_quotes(self): + filtr = {"col": "A", "op": "in", "val": ['"a"']} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("a", res.filter["filter"]["value"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_keeps_trailing_spaces(self): + filtr = {"col": "A", "op": "in", "val": ["a "]} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], [], column_dict) + self.assertEqual("a ", res.filter["filter"]["value"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_filters_converts_strings_to_num(self): + filtr = {"col": "A", "op": "in", "val": ["6"]} + col = DruidColumn(column_name="A") + column_dict = {"A": col} + res = DruidDatasource.get_filters([filtr], ["A"], column_dict) + self.assertEqual(6, res.filter["filter"]["value"]) + filtr = {"col": "A", "op": "==", "val": "6"} + res = DruidDatasource.get_filters([filtr], ["A"], column_dict) + self.assertEqual(6, res.filter["filter"]["value"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_run_query_no_groupby(self): + client = Mock() + from_dttm = Mock() + to_dttm = Mock() + from_dttm.replace = Mock(return_value=from_dttm) + to_dttm.replace = Mock(return_value=to_dttm) + from_dttm.isoformat = Mock(return_value="from") + to_dttm.isoformat = Mock(return_value="to") + timezone = "timezone" + from_dttm.tzname = Mock(return_value=timezone) + ds = DruidDatasource(datasource_name="datasource") + metric1 = DruidMetric(metric_name="metric1") + metric2 = DruidMetric(metric_name="metric2") + ds.metrics = [metric1, metric2] + col1 = DruidColumn(column_name="col1") + col2 = DruidColumn(column_name="col2") + ds.columns = [col1, col2] + aggs = [] + post_aggs = ["some_agg"] + ds._metrics_and_post_aggs = Mock(return_value=(aggs, post_aggs)) + columns = [] + metrics = ["metric1"] + ds.get_having_filters = Mock(return_value=[]) + client.query_builder = Mock() + client.query_builder.last_query = Mock() + client.query_builder.last_query.query_dict = {"mock": 0} + # no groupby calls client.timeseries + ds.run_query( + metrics, + None, + from_dttm, + to_dttm, + groupby=columns, + client=client, + filter=[], + row_limit=100, + ) + self.assertEqual(0, len(client.topn.call_args_list)) + self.assertEqual(0, len(client.groupby.call_args_list)) + self.assertEqual(1, len(client.timeseries.call_args_list)) + # check that there is no dimensions entry + called_args = client.timeseries.call_args_list[0][1] + self.assertNotIn("dimensions", called_args) + self.assertIn("post_aggregations", called_args) + # restore functions + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_run_query_with_adhoc_metric(self): + client = Mock() + from_dttm = Mock() + to_dttm = Mock() + from_dttm.replace = Mock(return_value=from_dttm) + to_dttm.replace = Mock(return_value=to_dttm) + from_dttm.isoformat = Mock(return_value="from") + to_dttm.isoformat = Mock(return_value="to") + timezone = "timezone" + from_dttm.tzname = Mock(return_value=timezone) + ds = DruidDatasource(datasource_name="datasource") + metric1 = DruidMetric(metric_name="metric1") + metric2 = DruidMetric(metric_name="metric2") + ds.metrics = [metric1, metric2] + col1 = DruidColumn(column_name="col1") + col2 = DruidColumn(column_name="col2") + ds.columns = [col1, col2] + all_metrics = [] + post_aggs = ["some_agg"] + ds._metrics_and_post_aggs = Mock(return_value=(all_metrics, post_aggs)) + columns = [] + metrics = [ + { + "expressionType": "SIMPLE", + "column": {"type": "DOUBLE", "column_name": "col1"}, + "aggregate": "SUM", + "label": "My Adhoc Metric", + } + ] + + ds.get_having_filters = Mock(return_value=[]) + client.query_builder = Mock() + client.query_builder.last_query = Mock() + client.query_builder.last_query.query_dict = {"mock": 0} + # no groupby calls client.timeseries + ds.run_query( + metrics, + None, + from_dttm, + to_dttm, + groupby=columns, + client=client, + filter=[], + row_limit=100, + ) + self.assertEqual(0, len(client.topn.call_args_list)) + self.assertEqual(0, len(client.groupby.call_args_list)) + self.assertEqual(1, len(client.timeseries.call_args_list)) + # check that there is no dimensions entry + called_args = client.timeseries.call_args_list[0][1] + self.assertNotIn("dimensions", called_args) + self.assertIn("post_aggregations", called_args) + # restore functions + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_run_query_single_groupby(self): + client = Mock() + from_dttm = Mock() + to_dttm = Mock() + from_dttm.replace = Mock(return_value=from_dttm) + to_dttm.replace = Mock(return_value=to_dttm) + from_dttm.isoformat = Mock(return_value="from") + to_dttm.isoformat = Mock(return_value="to") + timezone = "timezone" + from_dttm.tzname = Mock(return_value=timezone) + ds = DruidDatasource(datasource_name="datasource") + metric1 = DruidMetric(metric_name="metric1") + metric2 = DruidMetric(metric_name="metric2") + ds.metrics = [metric1, metric2] + col1 = DruidColumn(column_name="col1") + col2 = DruidColumn(column_name="col2") + ds.columns = [col1, col2] + aggs = ["metric1"] + post_aggs = ["some_agg"] + ds._metrics_and_post_aggs = Mock(return_value=(aggs, post_aggs)) + columns = ["col1"] + metrics = ["metric1"] + ds.get_having_filters = Mock(return_value=[]) + client.query_builder.last_query.query_dict = {"mock": 0} + # client.topn is called twice + ds.run_query( + metrics, + None, + from_dttm, + to_dttm, + groupby=columns, + timeseries_limit=100, + client=client, + order_desc=True, + filter=[], + ) + self.assertEqual(2, len(client.topn.call_args_list)) + self.assertEqual(0, len(client.groupby.call_args_list)) + self.assertEqual(0, len(client.timeseries.call_args_list)) + # check that there is no dimensions entry + called_args_pre = client.topn.call_args_list[0][1] + self.assertNotIn("dimensions", called_args_pre) + self.assertIn("dimension", called_args_pre) + called_args = client.topn.call_args_list[1][1] + self.assertIn("dimension", called_args) + self.assertEqual("col1", called_args["dimension"]) + # not order_desc + client = Mock() + client.query_builder.last_query.query_dict = {"mock": 0} + ds.run_query( + metrics, + None, + from_dttm, + to_dttm, + groupby=columns, + client=client, + order_desc=False, + filter=[], + row_limit=100, + ) + self.assertEqual(0, len(client.topn.call_args_list)) + self.assertEqual(1, len(client.groupby.call_args_list)) + self.assertEqual(0, len(client.timeseries.call_args_list)) + self.assertIn("dimensions", client.groupby.call_args_list[0][1]) + self.assertEqual(["col1"], client.groupby.call_args_list[0][1]["dimensions"]) + # order_desc but timeseries and dimension spec + # calls topn with single dimension spec 'dimension' + spec = {"outputName": "hello", "dimension": "matcho"} + spec_json = json.dumps(spec) + col3 = DruidColumn(column_name="col3", dimension_spec_json=spec_json) + ds.columns.append(col3) + groupby = ["col3"] + client = Mock() + client.query_builder.last_query.query_dict = {"mock": 0} + ds.run_query( + metrics, + None, + from_dttm, + to_dttm, + groupby=groupby, + client=client, + order_desc=True, + timeseries_limit=5, + filter=[], + row_limit=100, + ) + self.assertEqual(2, len(client.topn.call_args_list)) + self.assertEqual(0, len(client.groupby.call_args_list)) + self.assertEqual(0, len(client.timeseries.call_args_list)) + self.assertIn("dimension", client.topn.call_args_list[0][1]) + self.assertIn("dimension", client.topn.call_args_list[1][1]) + # uses dimension for pre query and full spec for final query + self.assertEqual("matcho", client.topn.call_args_list[0][1]["dimension"]) + self.assertEqual(spec, client.topn.call_args_list[1][1]["dimension"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_run_query_multiple_groupby(self): + client = Mock() + from_dttm = Mock() + to_dttm = Mock() + from_dttm.replace = Mock(return_value=from_dttm) + to_dttm.replace = Mock(return_value=to_dttm) + from_dttm.isoformat = Mock(return_value="from") + to_dttm.isoformat = Mock(return_value="to") + timezone = "timezone" + from_dttm.tzname = Mock(return_value=timezone) + ds = DruidDatasource(datasource_name="datasource") + metric1 = DruidMetric(metric_name="metric1") + metric2 = DruidMetric(metric_name="metric2") + ds.metrics = [metric1, metric2] + col1 = DruidColumn(column_name="col1") + col2 = DruidColumn(column_name="col2") + ds.columns = [col1, col2] + aggs = [] + post_aggs = ["some_agg"] + ds._metrics_and_post_aggs = Mock(return_value=(aggs, post_aggs)) + columns = ["col1", "col2"] + metrics = ["metric1"] + ds.get_having_filters = Mock(return_value=[]) + client.query_builder = Mock() + client.query_builder.last_query = Mock() + client.query_builder.last_query.query_dict = {"mock": 0} + # no groupby calls client.timeseries + ds.run_query( + metrics, + None, + from_dttm, + to_dttm, + groupby=columns, + client=client, + row_limit=100, + filter=[], + ) + self.assertEqual(0, len(client.topn.call_args_list)) + self.assertEqual(1, len(client.groupby.call_args_list)) + self.assertEqual(0, len(client.timeseries.call_args_list)) + # check that there is no dimensions entry + called_args = client.groupby.call_args_list[0][1] + self.assertIn("dimensions", called_args) + self.assertEqual(["col1", "col2"], called_args["dimensions"]) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_post_agg_returns_correct_agg_type(self): + get_post_agg = DruidDatasource.get_post_agg + # javascript PostAggregators + function = "function(field1, field2) { return field1 + field2; }" + conf = { + "type": "javascript", + "name": "postagg_name", + "fieldNames": ["field1", "field2"], + "function": function, + } + postagg = get_post_agg(conf) + self.assertTrue(isinstance(postagg, models.JavascriptPostAggregator)) + self.assertEqual(postagg.name, "postagg_name") + self.assertEqual(postagg.post_aggregator["type"], "javascript") + self.assertEqual(postagg.post_aggregator["fieldNames"], ["field1", "field2"]) + self.assertEqual(postagg.post_aggregator["name"], "postagg_name") + self.assertEqual(postagg.post_aggregator["function"], function) + # Quantile + conf = {"type": "quantile", "name": "postagg_name", "probability": "0.5"} + postagg = get_post_agg(conf) + self.assertTrue(isinstance(postagg, postaggs.Quantile)) + self.assertEqual(postagg.name, "postagg_name") + self.assertEqual(postagg.post_aggregator["probability"], "0.5") + # Quantiles + conf = { + "type": "quantiles", + "name": "postagg_name", + "probabilities": "0.4,0.5,0.6", + } + postagg = get_post_agg(conf) + self.assertTrue(isinstance(postagg, postaggs.Quantiles)) + self.assertEqual(postagg.name, "postagg_name") + self.assertEqual(postagg.post_aggregator["probabilities"], "0.4,0.5,0.6") + # FieldAccess + conf = {"type": "fieldAccess", "name": "field_name"} + postagg = get_post_agg(conf) + self.assertTrue(isinstance(postagg, postaggs.Field)) + self.assertEqual(postagg.name, "field_name") + # constant + conf = {"type": "constant", "value": 1234, "name": "postagg_name"} + postagg = get_post_agg(conf) + self.assertTrue(isinstance(postagg, postaggs.Const)) + self.assertEqual(postagg.name, "postagg_name") + self.assertEqual(postagg.post_aggregator["value"], 1234) + # hyperUniqueCardinality + conf = {"type": "hyperUniqueCardinality", "name": "unique_name"} + postagg = get_post_agg(conf) + self.assertTrue(isinstance(postagg, postaggs.HyperUniqueCardinality)) + self.assertEqual(postagg.name, "unique_name") + # arithmetic + conf = { + "type": "arithmetic", + "fn": "+", + "fields": ["field1", "field2"], + "name": "postagg_name", + } + postagg = get_post_agg(conf) + self.assertTrue(isinstance(postagg, postaggs.Postaggregator)) + self.assertEqual(postagg.name, "postagg_name") + self.assertEqual(postagg.post_aggregator["fn"], "+") + self.assertEqual(postagg.post_aggregator["fields"], ["field1", "field2"]) + # custom post aggregator + conf = {"type": "custom", "name": "custom_name", "stuff": "more_stuff"} + postagg = get_post_agg(conf) + self.assertTrue(isinstance(postagg, models.CustomPostAggregator)) + self.assertEqual(postagg.name, "custom_name") + self.assertEqual(postagg.post_aggregator["stuff"], "more_stuff") + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_find_postaggs_for_returns_postaggs_and_removes(self): + find_postaggs_for = DruidDatasource.find_postaggs_for + postagg_names = set(["pa2", "pa3", "pa4", "m1", "m2", "m3", "m4"]) + + metrics = {} + for i in range(1, 6): + emplace(metrics, "pa" + str(i), True) + emplace(metrics, "m" + str(i), False) + postagg_list = find_postaggs_for(postagg_names, metrics) + self.assertEqual(3, len(postagg_list)) + self.assertEqual(4, len(postagg_names)) + expected_metrics = ["m1", "m2", "m3", "m4"] + expected_postaggs = set(["pa2", "pa3", "pa4"]) + for postagg in postagg_list: + expected_postaggs.remove(postagg.metric_name) + for metric in expected_metrics: + postagg_names.remove(metric) + self.assertEqual(0, len(expected_postaggs)) + self.assertEqual(0, len(postagg_names)) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_recursive_get_fields(self): + conf = { + "type": "quantile", + "fieldName": "f1", + "field": { + "type": "custom", + "fields": [ + {"type": "fieldAccess", "fieldName": "f2"}, + {"type": "fieldAccess", "fieldName": "f3"}, + { + "type": "quantiles", + "fieldName": "f4", + "field": {"type": "custom"}, + }, + { + "type": "custom", + "fields": [ + {"type": "fieldAccess", "fieldName": "f5"}, + { + "type": "fieldAccess", + "fieldName": "f2", + "fields": [ + {"type": "fieldAccess", "fieldName": "f3"}, + {"type": "fieldIgnoreMe", "fieldName": "f6"}, + ], + }, + ], + }, + ], + }, + } + fields = DruidDatasource.recursive_get_fields(conf) + expected = set(["f1", "f2", "f3", "f4", "f5"]) + self.assertEqual(5, len(fields)) + for field in fields: + expected.remove(field) + self.assertEqual(0, len(expected)) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_metrics_and_post_aggs_tree(self): + metrics = ["A", "B", "m1", "m2"] + metrics_dict = {} + for i in range(ord("A"), ord("K") + 1): + emplace(metrics_dict, chr(i), True) + for i in range(1, 10): + emplace(metrics_dict, "m" + str(i), False) + + def depends_on(index, fields): + dependents = fields if isinstance(fields, list) else [fields] + metrics_dict[index].json_obj = {"fieldNames": dependents} + + depends_on("A", ["m1", "D", "C"]) + depends_on("B", ["B", "C", "E", "F", "m3"]) + depends_on("C", ["H", "I"]) + depends_on("D", ["m2", "m5", "G", "C"]) + depends_on("E", ["H", "I", "J"]) + depends_on("F", ["J", "m5"]) + depends_on("G", ["m4", "m7", "m6", "A"]) + depends_on("H", ["A", "m4", "I"]) + depends_on("I", ["H", "K"]) + depends_on("J", "K") + depends_on("K", ["m8", "m9"]) + aggs, postaggs = DruidDatasource.metrics_and_post_aggs(metrics, metrics_dict) + expected_metrics = set(aggs.keys()) + self.assertEqual(9, len(aggs)) + for i in range(1, 10): + expected_metrics.remove("m" + str(i)) + self.assertEqual(0, len(expected_metrics)) + self.assertEqual(11, len(postaggs)) + for i in range(ord("A"), ord("K") + 1): + del postaggs[chr(i)] + self.assertEqual(0, len(postaggs)) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_metrics_and_post_aggs(self): + """ + Test generation of metrics and post-aggregations from an initial list + of superset metrics (which may include the results of either). This + primarily tests that specifying a post-aggregator metric will also + require the raw aggregation of the associated druid metric column. + """ + metrics_dict = { + "unused_count": DruidMetric( + metric_name="unused_count", + verbose_name="COUNT(*)", + metric_type="count", + json=json.dumps({"type": "count", "name": "unused_count"}), + ), + "some_sum": DruidMetric( + metric_name="some_sum", + verbose_name="SUM(*)", + metric_type="sum", + json=json.dumps({"type": "sum", "name": "sum"}), + ), + "a_histogram": DruidMetric( + metric_name="a_histogram", + verbose_name="APPROXIMATE_HISTOGRAM(*)", + metric_type="approxHistogramFold", + json=json.dumps({"type": "approxHistogramFold", "name": "a_histogram"}), + ), + "aCustomMetric": DruidMetric( + metric_name="aCustomMetric", + verbose_name="MY_AWESOME_METRIC(*)", + metric_type="aCustomType", + json=json.dumps({"type": "customMetric", "name": "aCustomMetric"}), + ), + "quantile_p95": DruidMetric( + metric_name="quantile_p95", + verbose_name="P95(*)", + metric_type="postagg", + json=json.dumps( + { + "type": "quantile", + "probability": 0.95, + "name": "p95", + "fieldName": "a_histogram", + } + ), + ), + "aCustomPostAgg": DruidMetric( + metric_name="aCustomPostAgg", + verbose_name="CUSTOM_POST_AGG(*)", + metric_type="postagg", + json=json.dumps( + { + "type": "customPostAgg", + "name": "aCustomPostAgg", + "field": {"type": "fieldAccess", "fieldName": "aCustomMetric"}, + } + ), + ), + } + + adhoc_metric = { + "expressionType": "SIMPLE", + "column": {"type": "DOUBLE", "column_name": "value"}, + "aggregate": "SUM", + "label": "My Adhoc Metric", + } + + metrics = ["some_sum"] + saved_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs( + metrics, metrics_dict + ) + + assert set(saved_metrics.keys()) == {"some_sum"} + assert post_aggs == {} + + metrics = [adhoc_metric] + saved_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs( + metrics, metrics_dict + ) + + assert set(saved_metrics.keys()) == set([adhoc_metric["label"]]) + assert post_aggs == {} + + metrics = ["some_sum", adhoc_metric] + saved_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs( + metrics, metrics_dict + ) + + assert set(saved_metrics.keys()) == {"some_sum", adhoc_metric["label"]} + assert post_aggs == {} + + metrics = ["quantile_p95"] + saved_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs( + metrics, metrics_dict + ) + + result_postaggs = set(["quantile_p95"]) + assert set(saved_metrics.keys()) == {"a_histogram"} + assert set(post_aggs.keys()) == result_postaggs + + metrics = ["aCustomPostAgg"] + saved_metrics, post_aggs = DruidDatasource.metrics_and_post_aggs( + metrics, metrics_dict + ) + + result_postaggs = set(["aCustomPostAgg"]) + assert set(saved_metrics.keys()) == {"aCustomMetric"} + assert set(post_aggs.keys()) == result_postaggs + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_druid_type_from_adhoc_metric(self): + + druid_type = DruidDatasource.druid_type_from_adhoc_metric( + { + "column": {"type": "DOUBLE", "column_name": "value"}, + "aggregate": "SUM", + "label": "My Adhoc Metric", + } + ) + assert druid_type == "doubleSum" + + druid_type = DruidDatasource.druid_type_from_adhoc_metric( + { + "column": {"type": "LONG", "column_name": "value"}, + "aggregate": "MAX", + "label": "My Adhoc Metric", + } + ) + assert druid_type == "longMax" + + druid_type = DruidDatasource.druid_type_from_adhoc_metric( + { + "column": {"type": "VARCHAR(255)", "column_name": "value"}, + "aggregate": "COUNT", + "label": "My Adhoc Metric", + } + ) + assert druid_type == "count" + + druid_type = DruidDatasource.druid_type_from_adhoc_metric( + { + "column": {"type": "VARCHAR(255)", "column_name": "value"}, + "aggregate": "COUNT_DISTINCT", + "label": "My Adhoc Metric", + } + ) + assert druid_type == "cardinality" + + druid_type = DruidDatasource.druid_type_from_adhoc_metric( + { + "column": {"type": "hyperUnique", "column_name": "value"}, + "aggregate": "COUNT_DISTINCT", + "label": "My Adhoc Metric", + } + ) + assert druid_type == "hyperUnique" + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_run_query_order_by_metrics(self): + client = Mock() + client.query_builder.last_query.query_dict = {"mock": 0} + from_dttm = Mock() + to_dttm = Mock() + ds = DruidDatasource(datasource_name="datasource") + ds.get_having_filters = Mock(return_value=[]) + dim1 = DruidColumn(column_name="dim1") + dim2 = DruidColumn(column_name="dim2") + metrics_dict = { + "count1": DruidMetric( + metric_name="count1", + metric_type="count", + json=json.dumps({"type": "count", "name": "count1"}), + ), + "sum1": DruidMetric( + metric_name="sum1", + metric_type="doubleSum", + json=json.dumps({"type": "doubleSum", "name": "sum1"}), + ), + "sum2": DruidMetric( + metric_name="sum2", + metric_type="doubleSum", + json=json.dumps({"type": "doubleSum", "name": "sum2"}), + ), + "div1": DruidMetric( + metric_name="div1", + metric_type="postagg", + json=json.dumps( + { + "fn": "/", + "type": "arithmetic", + "name": "div1", + "fields": [ + {"fieldName": "sum1", "type": "fieldAccess"}, + {"fieldName": "sum2", "type": "fieldAccess"}, + ], + } + ), + ), + } + ds.columns = [dim1, dim2] + ds.metrics = list(metrics_dict.values()) + + columns = ["dim1"] + metrics = ["count1"] + granularity = "all" + # get the counts of the top 5 'dim1's, order by 'sum1' + ds.run_query( + metrics, + granularity, + from_dttm, + to_dttm, + groupby=columns, + timeseries_limit=5, + timeseries_limit_metric="sum1", + client=client, + order_desc=True, + filter=[], + ) + qry_obj = client.topn.call_args_list[0][1] + self.assertEqual("dim1", qry_obj["dimension"]) + self.assertEqual("sum1", qry_obj["metric"]) + aggregations = qry_obj["aggregations"] + post_aggregations = qry_obj["post_aggregations"] + self.assertEqual({"count1", "sum1"}, set(aggregations.keys())) + self.assertEqual(set(), set(post_aggregations.keys())) + + # get the counts of the top 5 'dim1's, order by 'div1' + ds.run_query( + metrics, + granularity, + from_dttm, + to_dttm, + groupby=columns, + timeseries_limit=5, + timeseries_limit_metric="div1", + client=client, + order_desc=True, + filter=[], + ) + qry_obj = client.topn.call_args_list[1][1] + self.assertEqual("dim1", qry_obj["dimension"]) + self.assertEqual("div1", qry_obj["metric"]) + aggregations = qry_obj["aggregations"] + post_aggregations = qry_obj["post_aggregations"] + self.assertEqual({"count1", "sum1", "sum2"}, set(aggregations.keys())) + self.assertEqual({"div1"}, set(post_aggregations.keys())) + + columns = ["dim1", "dim2"] + # get the counts of the top 5 ['dim1', 'dim2']s, order by 'sum1' + ds.run_query( + metrics, + granularity, + from_dttm, + to_dttm, + groupby=columns, + timeseries_limit=5, + timeseries_limit_metric="sum1", + client=client, + order_desc=True, + filter=[], + ) + qry_obj = client.groupby.call_args_list[0][1] + self.assertEqual({"dim1", "dim2"}, set(qry_obj["dimensions"])) + self.assertEqual("sum1", qry_obj["limit_spec"]["columns"][0]["dimension"]) + aggregations = qry_obj["aggregations"] + post_aggregations = qry_obj["post_aggregations"] + self.assertEqual({"count1", "sum1"}, set(aggregations.keys())) + self.assertEqual(set(), set(post_aggregations.keys())) + + # get the counts of the top 5 ['dim1', 'dim2']s, order by 'div1' + ds.run_query( + metrics, + granularity, + from_dttm, + to_dttm, + groupby=columns, + timeseries_limit=5, + timeseries_limit_metric="div1", + client=client, + order_desc=True, + filter=[], + ) + qry_obj = client.groupby.call_args_list[1][1] + self.assertEqual({"dim1", "dim2"}, set(qry_obj["dimensions"])) + self.assertEqual("div1", qry_obj["limit_spec"]["columns"][0]["dimension"]) + aggregations = qry_obj["aggregations"] + post_aggregations = qry_obj["post_aggregations"] + self.assertEqual({"count1", "sum1", "sum2"}, set(aggregations.keys())) + self.assertEqual({"div1"}, set(post_aggregations.keys())) + + @unittest.skipUnless( + SupersetTestCase.is_module_installed("pydruid"), "pydruid not installed" + ) + def test_get_aggregations(self): + ds = DruidDatasource(datasource_name="datasource") + metrics_dict = { + "sum1": DruidMetric( + metric_name="sum1", + metric_type="doubleSum", + json=json.dumps({"type": "doubleSum", "name": "sum1"}), + ), + "sum2": DruidMetric( + metric_name="sum2", + metric_type="doubleSum", + json=json.dumps({"type": "doubleSum", "name": "sum2"}), + ), + "div1": DruidMetric( + metric_name="div1", + metric_type="postagg", + json=json.dumps( + { + "fn": "/", + "type": "arithmetic", + "name": "div1", + "fields": [ + {"fieldName": "sum1", "type": "fieldAccess"}, + {"fieldName": "sum2", "type": "fieldAccess"}, + ], + } + ), + ), + } + metric_names = ["sum1", "sum2"] + aggs = ds.get_aggregations(metrics_dict, metric_names) + expected_agg = {name: metrics_dict[name].json_obj for name in metric_names} + self.assertEqual(expected_agg, aggs) + + metric_names = ["sum1", "col1"] + self.assertRaises( + SupersetException, ds.get_aggregations, metrics_dict, metric_names + ) + + metric_names = ["sum1", "div1"] + self.assertRaises( + SupersetException, ds.get_aggregations, metrics_dict, metric_names + ) diff --git a/tests/fixtures/certificates.py b/tests/fixtures/certificates.py new file mode 100644 index 000000000000..5cdf91770464 --- /dev/null +++ b/tests/fixtures/certificates.py @@ -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. +ssl_certificate = """-----BEGIN CERTIFICATE----- +MIIDnDCCAoQCCQCrdpcNPCA/eDANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMC +VVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEjAQBgNVBAcMCVNhbiBNYXRlbzEPMA0G +A1UECgwGUHJlc2V0MRMwEQYDVQQLDApTa3Vua3dvcmtzMRIwEAYDVQQDDAlwcmVz +ZXQuaW8xHTAbBgkqhkiG9w0BCQEWDmluZm9AcHJlc2V0LmlvMB4XDTIwMDMyNjEw +NTE1NFoXDTQwMDMyNjEwNTE1NFowgY8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApD +YWxpZm9ybmlhMRIwEAYDVQQHDAlTYW4gTWF0ZW8xDzANBgNVBAoMBlByZXNldDET +MBEGA1UECwwKU2t1bmt3b3JrczESMBAGA1UEAwwJcHJlc2V0LmlvMR0wGwYJKoZI +hvcNAQkBFg5pbmZvQHByZXNldC5pbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAKNHQZcu2L/6HvZfzy4Hnm3POeztfO+NJ7OzppAcNlLbTAatUk1YoDbJ +5m5GUW8m7pVEHb76UL6Xxei9MoMVvHGuXqQeZZnNd+DySW/227wkOPYOCVSuDsWD +1EReG+pv/z8CDhdwmMTkDTZUDr0BUR/yc8qTCPdZoalj2muDl+k2J3LSCkelx4U/ +2iYhoUQD+lzFS3k7ohAfaGc2aZOlwTITopXHSFfuZ7j9muBOYtU7NgpnCl6WgxYP +1+4ddBIauPTBY2gWfZC2FeOfYEqfsUUXRsw1ehEQf4uxxTKNJTfTuVbdgrTYx5QQ +jrM88WvWdyVnIM7u7/x9bawfGX/b/F0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA +XYLLk3T5RWIagNa3DPrMI+SjRm4PAI/RsijtBV+9hrkCXOQ1mvlo/ORniaiemHvF +Kh6u6MTl014+f6Ytg/tx/OzuK2ffo9x44ZV/yqkbSmKD1pGftYNqCnBCN0uo1Gzb +HZ+bTozo+9raFN7OGPgbdBmpQT2c+LG5n+7REobHFb7VLeY2/7BKtxNBRXfIxn4X ++MIhpASwLH5X64a1f9LyuPNMyUvKgzDe7jRdX1JZ7uw/1T//OHGQth0jLiapa6FZ +GwgYUaruSZH51ZtxrJSXKSNBA7asPSBbyOmGptLsw2GTAsoBd5sUR4+hbuVo+1ai +XeA3AKTX/OdYWJvr5YIgeQ== +-----END CERTIFICATE-----""" diff --git a/tests/fixtures/dataframes.py b/tests/fixtures/dataframes.py new file mode 100644 index 000000000000..e565dc40a002 --- /dev/null +++ b/tests/fixtures/dataframes.py @@ -0,0 +1,121 @@ +# 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. +from datetime import date + +from pandas import DataFrame, to_datetime + +names_df = DataFrame( + [ + { + "dt": date(2020, 1, 2), + "name": "John", + "country": "United Kingdom", + "cars": 3, + "bikes": 1, + "seconds": 30, + }, + { + "dt": date(2020, 1, 2), + "name": "Peter", + "country": "Sweden", + "cars": 4, + "bikes": 2, + "seconds": 1, + }, + { + "dt": date(2020, 1, 3), + "name": "Mary", + "country": "Finland", + "cars": 5, + "bikes": 3, + "seconds": None, + }, + { + "dt": date(2020, 1, 3), + "name": "Peter", + "country": "India", + "cars": 6, + "bikes": 4, + "seconds": 12, + }, + { + "dt": date(2020, 1, 4), + "name": "John", + "country": "Portugal", + "cars": 7, + "bikes": None, + "seconds": 75, + }, + { + "dt": date(2020, 1, 4), + "name": "Peter", + "country": "Italy", + "cars": None, + "bikes": 5, + "seconds": 600, + }, + { + "dt": date(2020, 1, 4), + "name": "Mary", + "country": None, + "cars": 9, + "bikes": 6, + "seconds": 2, + }, + { + "dt": date(2020, 1, 4), + "name": None, + "country": "Australia", + "cars": 10, + "bikes": 7, + "seconds": 99, + }, + { + "dt": date(2020, 1, 1), + "name": "John", + "country": "USA", + "cars": 1, + "bikes": 8, + "seconds": None, + }, + { + "dt": date(2020, 1, 1), + "name": "Mary", + "country": "Fiji", + "cars": 2, + "bikes": 9, + "seconds": 50, + }, + ] +) + +categories_df = DataFrame( + { + "constant": ["dummy" for _ in range(0, 101)], + "category": [f"cat{i%3}" for i in range(0, 101)], + "dept": [f"dept{i%5}" for i in range(0, 101)], + "name": [f"person{i}" for i in range(0, 101)], + "asc_idx": [i for i in range(0, 101)], + "desc_idx": [i for i in range(100, -1, -1)], + "idx_nulls": [i if i % 5 == 0 else None for i in range(0, 101)], + } +) + +timeseries_df = DataFrame( + index=to_datetime(["2019-01-01", "2019-01-02", "2019-01-05", "2019-01-07"]), + data={"label": ["x", "y", "z", "q"], "y": [1.0, 2.0, 3.0, 4.0]}, +) diff --git a/tests/jinja_context_tests.py b/tests/jinja_context_tests.py new file mode 100644 index 000000000000..91cb5e476139 --- /dev/null +++ b/tests/jinja_context_tests.py @@ -0,0 +1,90 @@ +# 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 json +from unittest import mock + +from superset.jinja_context import filter_values +from tests.base_tests import SupersetTestCase + + +class Jinja2ContextTests(SupersetTestCase): + def test_filter_values_default(self) -> None: + request = mock.MagicMock() + request.form = {} + + with mock.patch("superset.jinja_context.request", request): + self.assertEquals(filter_values("name", "foo"), ["foo"]) + + def test_filter_values_no_default(self) -> None: + request = mock.MagicMock() + request.form = {} + + with mock.patch("superset.jinja_context.request", request): + self.assertEquals(filter_values("name"), []) + + def test_filter_values_adhoc_filters(self) -> None: + request = mock.MagicMock() + + request.form = { + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": "foo", + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + + with mock.patch("superset.jinja_context.request", request): + self.assertEquals(filter_values("name"), ["foo"]) + + request.form = { + "form_data": json.dumps( + { + "adhoc_filters": [ + { + "clause": "WHERE", + "comparator": ["foo", "bar"], + "expressionType": "SIMPLE", + "operator": "in", + "subject": "name", + } + ], + } + ) + } + + with mock.patch("superset.jinja_context.request", request): + self.assertEquals(filter_values("name"), ["foo", "bar"]) + + def test_filter_values_extra_filters(self) -> None: + request = mock.MagicMock() + + request.form = { + "form_data": json.dumps( + {"extra_filters": [{"col": "name", "op": "in", "val": "foo"}]} + ) + } + + with mock.patch("superset.jinja_context.request", request): + self.assertEquals(filter_values("name"), ["foo"]) diff --git a/tests/pandas_postprocessing_tests.py b/tests/pandas_postprocessing_tests.py new file mode 100644 index 000000000000..a981477d0bb4 --- /dev/null +++ b/tests/pandas_postprocessing_tests.py @@ -0,0 +1,290 @@ +# 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. +# isort:skip_file +import math +from typing import Any, List + +from pandas import Series + +from superset.exceptions import QueryObjectValidationError +from superset.utils import pandas_postprocessing as proc + +from .base_tests import SupersetTestCase +from .fixtures.dataframes import categories_df, timeseries_df + + +def series_to_list(series: Series) -> List[Any]: + """ + Converts a `Series` to a regular list, and replaces non-numeric values to + Nones. + + :param series: Series to convert + :return: list without nan or inf + """ + return [ + None + if not isinstance(val, str) and (math.isnan(val) or math.isinf(val)) + else val + for val in series.tolist() + ] + + +class PostProcessingTestCase(SupersetTestCase): + def test_pivot(self): + aggregates = {"idx_nulls": {"operator": "sum"}} + + # regular pivot + df = proc.pivot( + df=categories_df, + index=["name"], + columns=["category"], + aggregates=aggregates, + ) + self.assertListEqual( + df.columns.tolist(), + [("idx_nulls", "cat0"), ("idx_nulls", "cat1"), ("idx_nulls", "cat2")], + ) + self.assertEqual(len(df), 101) + self.assertEqual(df.sum()[0], 315) + + # regular pivot + df = proc.pivot( + df=categories_df, + index=["dept"], + columns=["category"], + aggregates=aggregates, + ) + self.assertEqual(len(df), 5) + + # fill value + df = proc.pivot( + df=categories_df, + index=["name"], + columns=["category"], + metric_fill_value=1, + aggregates={"idx_nulls": {"operator": "sum"}}, + ) + self.assertEqual(df.sum()[0], 382) + + # invalid index reference + self.assertRaises( + QueryObjectValidationError, + proc.pivot, + df=categories_df, + index=["abc"], + columns=["dept"], + aggregates=aggregates, + ) + + # invalid column reference + self.assertRaises( + QueryObjectValidationError, + proc.pivot, + df=categories_df, + index=["dept"], + columns=["abc"], + aggregates=aggregates, + ) + + # invalid aggregate options + self.assertRaises( + QueryObjectValidationError, + proc.pivot, + df=categories_df, + index=["name"], + columns=["category"], + aggregates={"idx_nulls": {}}, + ) + + def test_aggregate(self): + aggregates = { + "asc sum": {"column": "asc_idx", "operator": "sum"}, + "asc q2": { + "column": "asc_idx", + "operator": "percentile", + "options": {"q": 75}, + }, + "desc q1": { + "column": "desc_idx", + "operator": "percentile", + "options": {"q": 25}, + }, + } + df = proc.aggregate( + df=categories_df, groupby=["constant"], aggregates=aggregates + ) + self.assertListEqual( + df.columns.tolist(), ["constant", "asc sum", "asc q2", "desc q1"] + ) + self.assertEqual(series_to_list(df["asc sum"])[0], 5050) + self.assertEqual(series_to_list(df["asc q2"])[0], 75) + self.assertEqual(series_to_list(df["desc q1"])[0], 25) + + def test_sort(self): + df = proc.sort(df=categories_df, columns={"category": True, "asc_idx": False}) + self.assertEqual(96, series_to_list(df["asc_idx"])[1]) + + self.assertRaises( + QueryObjectValidationError, proc.sort, df=df, columns={"abc": True} + ) + + def test_rolling(self): + # sum rolling type + post_df = proc.rolling( + df=timeseries_df, + columns={"y": "y"}, + rolling_type="sum", + window=2, + min_periods=0, + ) + + self.assertListEqual(post_df.columns.tolist(), ["label", "y"]) + self.assertListEqual(series_to_list(post_df["y"]), [1.0, 3.0, 5.0, 7.0]) + + # mean rolling type with alias + post_df = proc.rolling( + df=timeseries_df, + rolling_type="mean", + columns={"y": "y_mean"}, + window=10, + min_periods=0, + ) + self.assertListEqual(post_df.columns.tolist(), ["label", "y", "y_mean"]) + self.assertListEqual(series_to_list(post_df["y_mean"]), [1.0, 1.5, 2.0, 2.5]) + + # count rolling type + post_df = proc.rolling( + df=timeseries_df, + rolling_type="count", + columns={"y": "y"}, + window=10, + min_periods=0, + ) + self.assertListEqual(post_df.columns.tolist(), ["label", "y"]) + self.assertListEqual(series_to_list(post_df["y"]), [1.0, 2.0, 3.0, 4.0]) + + # quantile rolling type + post_df = proc.rolling( + df=timeseries_df, + columns={"y": "q1"}, + rolling_type="quantile", + rolling_type_options={"quantile": 0.25}, + window=10, + min_periods=0, + ) + self.assertListEqual(post_df.columns.tolist(), ["label", "y", "q1"]) + self.assertListEqual(series_to_list(post_df["q1"]), [1.0, 1.25, 1.5, 1.75]) + + # incorrect rolling type + self.assertRaises( + QueryObjectValidationError, + proc.rolling, + df=timeseries_df, + columns={"y": "y"}, + rolling_type="abc", + window=2, + ) + + # incorrect rolling type options + self.assertRaises( + QueryObjectValidationError, + proc.rolling, + df=timeseries_df, + columns={"y": "y"}, + rolling_type="quantile", + rolling_type_options={"abc": 123}, + window=2, + ) + + def test_select(self): + # reorder columns + post_df = proc.select(df=timeseries_df, columns=["y", "label"]) + self.assertListEqual(post_df.columns.tolist(), ["y", "label"]) + + # one column + post_df = proc.select(df=timeseries_df, columns=["label"]) + self.assertListEqual(post_df.columns.tolist(), ["label"]) + + # rename one column + post_df = proc.select(df=timeseries_df, columns=["y"], rename={"y": "y1"}) + self.assertListEqual(post_df.columns.tolist(), ["y1"]) + + # rename one and leave one unchanged + post_df = proc.select( + df=timeseries_df, columns=["label", "y"], rename={"y": "y1"} + ) + self.assertListEqual(post_df.columns.tolist(), ["label", "y1"]) + + # invalid columns + self.assertRaises( + QueryObjectValidationError, + proc.select, + df=timeseries_df, + columns=["qwerty"], + rename={"abc": "qwerty"}, + ) + + def test_diff(self): + # overwrite column + post_df = proc.diff(df=timeseries_df, columns={"y": "y"}) + self.assertListEqual(post_df.columns.tolist(), ["label", "y"]) + self.assertListEqual(series_to_list(post_df["y"]), [None, 1.0, 1.0, 1.0]) + + # add column + post_df = proc.diff(df=timeseries_df, columns={"y": "y1"}) + self.assertListEqual(post_df.columns.tolist(), ["label", "y", "y1"]) + self.assertListEqual(series_to_list(post_df["y"]), [1.0, 2.0, 3.0, 4.0]) + self.assertListEqual(series_to_list(post_df["y1"]), [None, 1.0, 1.0, 1.0]) + + # look ahead + post_df = proc.diff(df=timeseries_df, columns={"y": "y1"}, periods=-1) + self.assertListEqual(series_to_list(post_df["y1"]), [-1.0, -1.0, -1.0, None]) + + # invalid column reference + self.assertRaises( + QueryObjectValidationError, + proc.diff, + df=timeseries_df, + columns={"abc": "abc"}, + ) + + def test_cum(self): + # create new column (cumsum) + post_df = proc.cum(df=timeseries_df, columns={"y": "y2"}, operator="sum",) + self.assertListEqual(post_df.columns.tolist(), ["label", "y", "y2"]) + self.assertListEqual(series_to_list(post_df["label"]), ["x", "y", "z", "q"]) + self.assertListEqual(series_to_list(post_df["y"]), [1.0, 2.0, 3.0, 4.0]) + self.assertListEqual(series_to_list(post_df["y2"]), [1.0, 3.0, 6.0, 10.0]) + + # overwrite column (cumprod) + post_df = proc.cum(df=timeseries_df, columns={"y": "y"}, operator="prod",) + self.assertListEqual(post_df.columns.tolist(), ["label", "y"]) + self.assertListEqual(series_to_list(post_df["y"]), [1.0, 2.0, 6.0, 24.0]) + + # overwrite column (cummin) + post_df = proc.cum(df=timeseries_df, columns={"y": "y"}, operator="min",) + self.assertListEqual(post_df.columns.tolist(), ["label", "y"]) + self.assertListEqual(series_to_list(post_df["y"]), [1.0, 1.0, 1.0, 1.0]) + + # invalid operator + self.assertRaises( + QueryObjectValidationError, + proc.cum, + df=timeseries_df, + columns={"y": "y"}, + operator="abc", + ) diff --git a/tests/queries/__init__.py b/tests/queries/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/tests/queries/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/queries/api_tests.py b/tests/queries/api_tests.py new file mode 100644 index 000000000000..4f43c7733500 --- /dev/null +++ b/tests/queries/api_tests.py @@ -0,0 +1,247 @@ +# 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. +# isort:skip_file +"""Unit tests for Superset""" +import json +import random +import string +from typing import Dict, Any + +import prison +from sqlalchemy.sql import func + +import tests.test_app +from superset import db, security_manager +from superset.models.core import Database +from superset.utils.core import get_example_database +from superset.models.sql_lab import Query + +from tests.base_tests import SupersetTestCase + + +class QueryApiTests(SupersetTestCase): + def insert_query( + self, + database_id: int, + user_id: int, + client_id: str, + sql: str = "", + select_sql: str = "", + executed_sql: str = "", + limit: int = 100, + progress: int = 100, + rows: int = 100, + tab_name: str = "", + status: str = "success", + ) -> Query: + database = db.session.query(Database).get(database_id) + user = db.session.query(security_manager.user_model).get(user_id) + query = Query( + database=database, + user=user, + client_id=client_id, + sql=sql, + select_sql=select_sql, + executed_sql=executed_sql, + limit=limit, + progress=progress, + rows=rows, + tab_name=tab_name, + status=status, + ) + db.session.add(query) + db.session.commit() + return query + + @staticmethod + def get_random_string(length: int = 10): + letters = string.ascii_letters + return "".join(random.choice(letters) for i in range(length)) + + def test_get_query(self): + """ + Query API: Test get query + """ + admin = self.get_user("admin") + client_id = self.get_random_string() + query = self.insert_query( + get_example_database().id, + admin.id, + client_id, + sql="SELECT col1, col2 from table1", + select_sql="SELECT col1, col2 from table1", + executed_sql="SELECT col1, col2 from table1 LIMIT 100", + ) + self.login(username="admin") + uri = f"api/v1/query/{query.id}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + + expected_result = { + "client_id": client_id, + "end_result_backend_time": None, + "error_message": None, + "executed_sql": "SELECT col1, col2 from table1 LIMIT 100", + "limit": 100, + "progress": 100, + "results_key": None, + "rows": 100, + "schema": None, + "select_as_cta": None, + "select_as_cta_used": False, + "select_sql": "SELECT col1, col2 from table1", + "sql": "SELECT col1, col2 from table1", + "sql_editor_id": None, + "status": "success", + "tab_name": "", + "tmp_schema_name": None, + "tmp_table_name": None, + "tracking_url": None, + } + data = json.loads(rv.data.decode("utf-8")) + self.assertIn("changed_on", data["result"]) + for key, value in data["result"].items(): + # We can't assert timestamp + if key not in ( + "changed_on", + "end_time", + "start_running_time", + "start_time", + ): + self.assertEqual(value, expected_result[key]) + # rollback changes + db.session.delete(query) + db.session.commit() + + def test_get_query_not_found(self): + """ + Query API: Test get query not found + """ + admin = self.get_user("admin") + client_id = self.get_random_string() + self.insert_query(get_example_database().id, admin.id, client_id) + max_id = db.session.query(func.max(Query.id)).scalar() + self.login(username="admin") + uri = f"api/v1/query/{max_id + 1}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + + def test_get_query_no_data_access(self): + """ + Query API: Test get dashboard without data access + """ + gamma1 = self.create_user( + "gamma_1", "password", "Gamma", email="gamma1@superset.org" + ) + gamma2 = self.create_user( + "gamma_2", "password", "Gamma", email="gamma2@superset.org" + ) + + gamma1_client_id = self.get_random_string() + gamma2_client_id = self.get_random_string() + query_gamma1 = self.insert_query( + get_example_database().id, gamma1.id, gamma1_client_id + ) + query_gamma2 = self.insert_query( + get_example_database().id, gamma2.id, gamma2_client_id + ) + + # Gamma1 user, only sees his own queries + self.login(username="gamma_1", password="password") + uri = f"api/v1/query/{query_gamma2.id}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + uri = f"api/v1/query/{query_gamma1.id}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + + # Gamma2 user, only sees his own queries + self.logout() + self.login(username="gamma_2", password="password") + uri = f"api/v1/query/{query_gamma1.id}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + uri = f"api/v1/query/{query_gamma2.id}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + + # Admin's have the "all query access" permission + self.logout() + self.login(username="admin") + uri = f"api/v1/query/{query_gamma1.id}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + uri = f"api/v1/query/{query_gamma2.id}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + + # rollback changes + db.session.delete(query_gamma1) + db.session.delete(query_gamma2) + db.session.delete(gamma1) + db.session.delete(gamma2) + db.session.commit() + + def test_get_query_filter(self): + """ + Query API: Test get queries filter + """ + admin = self.get_user("admin") + client_id = self.get_random_string() + query = self.insert_query( + get_example_database().id, + admin.id, + client_id, + sql="SELECT col1, col2 from table1", + ) + + self.login(username="admin") + arguments = {"filters": [{"col": "sql", "opr": "sw", "value": "SELECT col1"}]} + uri = f"api/v1/query/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["count"], 1) + + # rollback changes + db.session.delete(query) + db.session.commit() + + def test_get_queries_no_data_access(self): + """ + Query API: Test get queries no data access + """ + admin = self.get_user("admin") + client_id = self.get_random_string() + query = self.insert_query( + get_example_database().id, + admin.id, + client_id, + sql="SELECT col1, col2 from table1", + ) + + self.login(username="gamma") + arguments = {"filters": [{"col": "sql", "opr": "sw", "value": "SELECT col1"}]} + uri = f"api/v1/query/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["count"], 0) + + # rollback changes + db.session.delete(query) + db.session.commit() diff --git a/tests/security_tests.py b/tests/security_tests.py index 7b8df262fea4..476b67019f15 100644 --- a/tests/security_tests.py +++ b/tests/security_tests.py @@ -833,10 +833,11 @@ def setUp(self): self.rls_entry.table = ( session.query(SqlaTable).filter_by(table_name="birth_names").first() ) - self.rls_entry.clause = "gender = 'male'" + self.rls_entry.clause = "gender = 'boy'" self.rls_entry.roles.append( security_manager.find_role("Gamma") ) # db.session.query(Role).filter_by(name="Gamma").first()) + self.rls_entry.roles.append(security_manager.find_role("Alpha")) db.session.add(self.rls_entry) db.session.commit() @@ -849,7 +850,7 @@ def tearDown(self): # Do another test to make sure it doesn't alter another query def test_rls_filter_alters_query(self): g.user = self.get_user( - username="gamma" + username="alpha" ) # self.login() doesn't actually set the user tbl = self.get_table_by_name("birth_names") query_obj = dict( @@ -864,7 +865,7 @@ def test_rls_filter_alters_query(self): extras={}, ) sql = tbl.get_query_str(query_obj) - self.assertIn("gender = 'male'", sql) + self.assertIn("gender = 'boy'", sql) def test_rls_filter_doesnt_alter_query(self): g.user = self.get_user( @@ -883,4 +884,4 @@ def test_rls_filter_doesnt_alter_query(self): extras={}, ) sql = tbl.get_query_str(query_obj) - self.assertNotIn("gender = 'male'", sql) + self.assertNotIn("gender = 'boy'", sql) diff --git a/tests/sqllab_tests.py b/tests/sqllab_tests.py index ad130a8ee2bf..bbc63d292e1e 100644 --- a/tests/sqllab_tests.py +++ b/tests/sqllab_tests.py @@ -332,6 +332,22 @@ def test_sqllab_viz(self): table = db.session.query(SqlaTable).filter_by(id=table_id).one() self.assertEqual([owner.username for owner in table.owners], ["admin"]) + def test_sqllab_table_viz(self): + self.login("admin") + examples_dbid = get_example_database().id + payload = {"datasourceName": "ab_role", "columns": [], "dbId": examples_dbid} + + data = {"data": json.dumps(payload)} + resp = self.get_json_resp("/superset/get_or_create_table/", data=data) + self.assertIn("table_id", resp) + + # ensure owner is set correctly + table_id = resp["table_id"] + table = db.session.query(SqlaTable).filter_by(id=table_id).one() + self.assertEqual([owner.username for owner in table.owners], ["admin"]) + db.session.delete(table) + db.session.commit() + def test_sql_limit(self): self.login("admin") test_limit = 1 diff --git a/tests/superset_test_config.py b/tests/superset_test_config.py index d95b91a089e7..0b8bff7d8af8 100644 --- a/tests/superset_test_config.py +++ b/tests/superset_test_config.py @@ -14,9 +14,11 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +# type: ignore from copy import copy -from superset.config import * # type: ignore +from superset.config import * +from tests.superset_test_custom_template_processors import CustomPrestoTemplateProcessor AUTH_USER_REGISTRATION_ROLE = "alpha" SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(DATA_DIR, "unittests.db") @@ -49,10 +51,16 @@ def GET_FEATURE_FLAGS_FUNC(ff): class CeleryConfig(object): - BROKER_URL = "redis://localhost" + BROKER_URL = "redis://{}:{}".format( + os.environ.get("REDIS_HOST", "localhost"), os.environ.get("REDIS_PORT", "6379") + ) CELERY_IMPORTS = ("superset.sql_lab",) CELERY_ANNOTATIONS = {"sql_lab.add": {"rate_limit": "10/s"}} CONCURRENCY = 1 CELERY_CONFIG = CeleryConfig + +CUSTOM_TEMPLATE_PROCESSORS = { + CustomPrestoTemplateProcessor.engine: CustomPrestoTemplateProcessor +} diff --git a/tests/superset_test_config_sqllab_backend_persist.py b/tests/superset_test_config_sqllab_backend_persist.py index 86619a2ff739..590a37865ff5 100644 --- a/tests/superset_test_config_sqllab_backend_persist.py +++ b/tests/superset_test_config_sqllab_backend_persist.py @@ -15,49 +15,10 @@ # specific language governing permissions and limitations # under the License. # flake8: noqa +# type: ignore import os from copy import copy -from superset.config import * # type: ignore - -AUTH_USER_REGISTRATION_ROLE = "alpha" -SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(DATA_DIR, "unittests.db") -DEBUG = True -SUPERSET_WEBSERVER_PORT = 8081 - -# Allowing SQLALCHEMY_DATABASE_URI to be defined as an env var for -# continuous integration -if "SUPERSET__SQLALCHEMY_DATABASE_URI" in os.environ: - SQLALCHEMY_DATABASE_URI = os.environ["SUPERSET__SQLALCHEMY_DATABASE_URI"] - -SQL_MAX_ROW = 666 -SQLLAB_CTAS_NO_LIMIT = True # SQL_MAX_ROW will not take affect for the CTA queries -FEATURE_FLAGS = {"foo": "bar"} - - -def GET_FEATURE_FLAGS_FUNC(ff): - ff_copy = copy(ff) - ff_copy["super"] = "set" - return ff_copy - - -TESTING = True -SECRET_KEY = "thisismyscretkey" -WTF_CSRF_ENABLED = False -PUBLIC_ROLE_LIKE_GAMMA = True -AUTH_ROLE_PUBLIC = "Public" -EMAIL_NOTIFICATIONS = False - -CACHE_CONFIG = {"CACHE_TYPE": "simple"} - - -class CeleryConfig(object): - BROKER_URL = "redis://localhost" - CELERY_IMPORTS = ("superset.sql_lab",) - CELERY_ANNOTATIONS = {"sql_lab.add": {"rate_limit": "10/s"}} - CONCURRENCY = 1 - - -CELERY_CONFIG = CeleryConfig +from .superset_test_config import * DEFAULT_FEATURE_FLAGS = {"SQLLAB_BACKEND_PERSISTENCE": True} diff --git a/tests/superset_test_config_thumbnails.py b/tests/superset_test_config_thumbnails.py new file mode 100644 index 000000000000..bf68df5e05d6 --- /dev/null +++ b/tests/superset_test_config_thumbnails.py @@ -0,0 +1,78 @@ +# 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. +# type: ignore +from copy import copy + +from flask import Flask +from werkzeug.contrib.cache import RedisCache + +from superset.config import * # type: ignore + +AUTH_USER_REGISTRATION_ROLE = "alpha" +SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(DATA_DIR, "unittests.db") +DEBUG = True +SUPERSET_WEBSERVER_PORT = 8081 + +# Allowing SQLALCHEMY_DATABASE_URI to be defined as an env var for +# continuous integration +if "SUPERSET__SQLALCHEMY_DATABASE_URI" in os.environ: + SQLALCHEMY_DATABASE_URI = os.environ["SUPERSET__SQLALCHEMY_DATABASE_URI"] + +SQL_SELECT_AS_CTA = True +SQL_MAX_ROW = 666 + + +def GET_FEATURE_FLAGS_FUNC(ff): + ff_copy = copy(ff) + ff_copy["super"] = "set" + return ff_copy + + +TESTING = True +WTF_CSRF_ENABLED = False +PUBLIC_ROLE_LIKE_GAMMA = True +AUTH_ROLE_PUBLIC = "Public" +EMAIL_NOTIFICATIONS = False + +CACHE_CONFIG = {"CACHE_TYPE": "simple"} + + +class CeleryConfig(object): + BROKER_URL = "redis://localhost" + CELERY_IMPORTS = ("superset.sql_lab", "superset.tasks.thumbnails") + CELERY_ANNOTATIONS = {"sql_lab.add": {"rate_limit": "10/s"}} + CONCURRENCY = 1 + + +CELERY_CONFIG = CeleryConfig + +FEATURE_FLAGS = { + "foo": "bar", + "KV_STORE": False, + "SHARE_QUERIES_VIA_KV_STORE": False, + "THUMBNAILS": True, + "THUMBNAILS_SQLA_LISTENERS": False, +} + + +def init_thumbnail_cache(app: Flask) -> RedisCache: + return RedisCache( + host="localhost", key_prefix="superset_thumbnails_", default_timeout=10000 + ) + + +THUMBNAIL_CACHE_CONFIG = init_thumbnail_cache diff --git a/tests/superset_test_custom_template_processors.py b/tests/superset_test_custom_template_processors.py new file mode 100644 index 000000000000..28fc65da428b --- /dev/null +++ b/tests/superset_test_custom_template_processors.py @@ -0,0 +1,59 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import re +from datetime import datetime, timedelta +from functools import partial +from typing import Any, Dict, SupportsInt + +from superset.jinja_context import PrestoTemplateProcessor + + +def DATE( + ts: datetime, day_offset: SupportsInt = 0, hour_offset: SupportsInt = 0 +) -> str: + """Current day as a string""" + day_offset, hour_offset = int(day_offset), int(hour_offset) + offset_day = (ts + timedelta(days=day_offset, hours=hour_offset)).date() + return str(offset_day) + + +class CustomPrestoTemplateProcessor(PrestoTemplateProcessor): + """A custom presto template processor for test.""" + + engine = "presto" + + def process_template(self, sql: str, **kwargs) -> str: + """Processes a sql template with $ style macro using regex.""" + # Add custom macros functions. + macros = {"DATE": partial(DATE, datetime.utcnow())} # type: Dict[str, Any] + # Update with macros defined in context and kwargs. + macros.update(self.context) + macros.update(kwargs) + + def replacer(match): + """Expands $ style macros with corresponding function calls.""" + macro_name, args_str = match.groups() + args = [a.strip() for a in args_str.split(",")] + if args == [""]: + args = [] + f = macros[macro_name[1:]] + return f(*args) + + macro_names = ["$" + name for name in macros.keys()] + pattern = r"(%s)\s*\(([^()]*)\)" % "|".join(map(re.escape, macro_names)) + return re.sub(pattern, replacer, sql) diff --git a/tests/thumbnails_tests.py b/tests/thumbnails_tests.py new file mode 100644 index 000000000000..ac8f16b2709c --- /dev/null +++ b/tests/thumbnails_tests.py @@ -0,0 +1,261 @@ +# 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. +# from superset import db +# from superset.models.dashboard import Dashboard +import subprocess +import urllib.request +from unittest import skipUnless +from unittest.mock import patch + +from flask_testing import LiveServerTestCase +from sqlalchemy.sql import func + +from superset import db, is_feature_enabled, security_manager, thumbnail_cache +from superset.models.dashboard import Dashboard +from superset.models.slice import Slice +from superset.utils.screenshots import ( + ChartScreenshot, + DashboardScreenshot, + get_auth_cookies, +) +from tests.test_app import app + +from .base_tests import SupersetTestCase + + +class CeleryStartMixin: + @classmethod + def setUpClass(cls): + with app.app_context(): + from werkzeug.contrib.cache import RedisCache + + class CeleryConfig(object): + BROKER_URL = "redis://localhost" + CELERY_IMPORTS = ("superset.tasks.thumbnails",) + CONCURRENCY = 1 + + app.config["CELERY_CONFIG"] = CeleryConfig + + def init_thumbnail_cache(app) -> RedisCache: + return RedisCache( + host="localhost", + key_prefix="superset_thumbnails_", + default_timeout=10000, + ) + + app.config["THUMBNAIL_CACHE_CONFIG"] = init_thumbnail_cache + + base_dir = app.config["BASE_DIR"] + worker_command = base_dir + "/bin/superset worker -w 2" + subprocess.Popen( + worker_command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + @classmethod + def tearDownClass(cls): + subprocess.call( + "ps auxww | grep 'celeryd' | awk '{print $2}' | xargs kill -9", shell=True + ) + subprocess.call( + "ps auxww | grep 'superset worker' | awk '{print $2}' | xargs kill -9", + shell=True, + ) + + +class ThumbnailsSeleniumLive(CeleryStartMixin, LiveServerTestCase): + def create_app(self): + return app + + def url_open_auth(self, username: str, url: str): + admin_user = security_manager.find_user(username=username) + cookies = {} + for cookie in get_auth_cookies(admin_user): + cookies["session"] = cookie + + opener = urllib.request.build_opener() + opener.addheaders.append(("Cookie", f"session={cookies['session']}")) + return opener.open(f"{self.get_server_url()}/{url}") + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_async_dashboard_screenshot(self): + """ + Thumbnails: Simple get async dashboard screenshot + """ + dashboard = db.session.query(Dashboard).all()[0] + with patch("superset.dashboards.api.DashboardRestApi.get") as mock_get: + response = self.url_open_auth( + "admin", + f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/", + ) + self.assertEqual(response.getcode(), 202) + + +class ThumbnailsTests(CeleryStartMixin, SupersetTestCase): + + mock_image = b"bytes mock image" + + def test_dashboard_thumbnail_disabled(self): + """ + Thumbnails: Dashboard thumbnail disabled + """ + if is_feature_enabled("THUMBNAILS"): + return + dashboard = db.session.query(Dashboard).all()[0] + self.login(username="admin") + uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + + def test_chart_thumbnail_disabled(self): + """ + Thumbnails: Chart thumbnail disabled + """ + if is_feature_enabled("THUMBNAILS"): + return + chart = db.session.query(Slice).all()[0] + self.login(username="admin") + uri = f"api/v1/chart/{chart}/thumbnail/{chart.digest}/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_async_dashboard_screenshot(self): + """ + Thumbnails: Simple get async dashboard screenshot + """ + dashboard = db.session.query(Dashboard).all()[0] + self.login(username="admin") + uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" + with patch( + "superset.tasks.thumbnails.cache_dashboard_thumbnail.delay" + ) as mock_task: + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 202) + mock_task.assert_called_with(dashboard.id, force=True) + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_async_dashboard_notfound(self): + """ + Thumbnails: Simple get async dashboard not found + """ + max_id = db.session.query(func.max(Dashboard.id)).scalar() + self.login(username="admin") + uri = f"api/v1/dashboard/{max_id + 1}/thumbnail/1234/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_async_dashboard_not_allowed(self): + """ + Thumbnails: Simple get async dashboard not allowed + """ + dashboard = db.session.query(Dashboard).all()[0] + self.login(username="gamma") + uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_async_chart_screenshot(self): + """ + Thumbnails: Simple get async chart screenshot + """ + chart = db.session.query(Slice).all()[0] + self.login(username="admin") + uri = f"api/v1/chart/{chart.id}/thumbnail/{chart.digest}/" + with patch( + "superset.tasks.thumbnails.cache_chart_thumbnail.delay" + ) as mock_task: + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 202) + mock_task.assert_called_with(chart.id, force=True) + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_async_chart_notfound(self): + """ + Thumbnails: Simple get async chart not found + """ + max_id = db.session.query(func.max(Slice.id)).scalar() + self.login(username="admin") + uri = f"api/v1/chart/{max_id + 1}/thumbnail/1234/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_cached_chart_wrong_digest(self): + """ + Thumbnails: Simple get chart with wrong digest + """ + chart = db.session.query(Slice).all()[0] + # Cache a test "image" + screenshot = ChartScreenshot(model_id=chart.id) + thumbnail_cache.set(screenshot.cache_key, self.mock_image) + self.login(username="admin") + uri = f"api/v1/chart/{chart.id}/thumbnail/1234/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 302) + self.assertRedirects(rv, f"api/v1/chart/{chart.id}/thumbnail/{chart.digest}/") + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_cached_dashboard_screenshot(self): + """ + Thumbnails: Simple get cached dashboard screenshot + """ + dashboard = db.session.query(Dashboard).all()[0] + # Cache a test "image" + screenshot = DashboardScreenshot(model_id=dashboard.id) + thumbnail_cache.set(screenshot.cache_key, self.mock_image) + self.login(username="admin") + uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.data, self.mock_image) + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_cached_chart_screenshot(self): + """ + Thumbnails: Simple get cached chart screenshot + """ + chart = db.session.query(Slice).all()[0] + # Cache a test "image" + screenshot = ChartScreenshot(model_id=chart.id) + thumbnail_cache.set(screenshot.cache_key, self.mock_image) + self.login(username="admin") + uri = f"api/v1/chart/{chart.id}/thumbnail/{chart.digest}/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.data, self.mock_image) + + @skipUnless((is_feature_enabled("THUMBNAILS")), "Thumbnails feature") + def test_get_cached_dashboard_wrong_digest(self): + """ + Thumbnails: Simple get dashboard with wrong digest + """ + dashboard = db.session.query(Dashboard).all()[0] + # Cache a test "image" + screenshot = DashboardScreenshot(model_id=dashboard.id) + thumbnail_cache.set(screenshot.cache_key, self.mock_image) + self.login(username="admin") + uri = f"api/v1/dashboard/{dashboard.id}/thumbnail/1234/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 302) + self.assertRedirects( + rv, f"api/v1/dashboard/{dashboard.id}/thumbnail/{dashboard.digest}/" + ) diff --git a/tests/utils_tests.py b/tests/utils_tests.py index 53320071a023..70b77afeac02 100644 --- a/tests/utils_tests.py +++ b/tests/utils_tests.py @@ -19,6 +19,8 @@ import uuid from datetime import date, datetime, time, timedelta from decimal import Decimal +import hashlib +import os from unittest.mock import Mock, patch import numpy @@ -28,15 +30,17 @@ import tests.test_app from superset import app, db, security_manager -from superset.exceptions import SupersetException +from superset.exceptions import CertificateException, SupersetException from superset.models.core import Database from superset.utils.cache_manager import CacheManager from superset.utils.core import ( base_json_conv, convert_legacy_filters_into_adhoc, + create_ssl_cert_file, datetime_f, format_timedelta, get_iterable, + get_email_address_list, get_or_create_db, get_since_until, get_stacktrace, @@ -46,6 +50,7 @@ memoized, merge_extra_filters, merge_request_params, + parse_ssl_cert, parse_human_timedelta, parse_js_uri_path_item, parse_past_timedelta, @@ -59,6 +64,8 @@ from superset.views.utils import build_extra_filters from tests.base_tests import SupersetTestCase +from .fixtures.certificates import ssl_certificate + def mock_parse_human_datetime(s): if s == "now": @@ -1221,3 +1228,24 @@ def test_build_extra_filters(self): ) expected = [] self.assertEqual(extra_filters, expected) + + def test_ssl_certificate_parse(self): + parsed_certificate = parse_ssl_cert(ssl_certificate) + self.assertEqual(parsed_certificate.serial_number, 12355228710836649848) + self.assertRaises(CertificateException, parse_ssl_cert, "abc" + ssl_certificate) + + def test_ssl_certificate_file_creation(self): + path = create_ssl_cert_file(ssl_certificate) + expected_filename = hashlib.md5(ssl_certificate.encode("utf-8")).hexdigest() + self.assertIn(expected_filename, path) + self.assertTrue(os.path.exists(path)) + + def test_get_email_address_list(self): + self.assertEqual(get_email_address_list("a@a"), ["a@a"]) + self.assertEqual(get_email_address_list(" a@a "), ["a@a"]) + self.assertEqual(get_email_address_list("a@a\n"), ["a@a"]) + self.assertEqual(get_email_address_list(",a@a;"), ["a@a"]) + self.assertEqual( + get_email_address_list(",a@a; b@b c@c a-c@c; d@d, f@f"), + ["a@a", "b@b", "c@c", "a-c@c", "d@d", "f@f"], + ) diff --git a/tests/viz_tests.py b/tests/viz_tests.py index 80da72ec5294..ab10f4970665 100644 --- a/tests/viz_tests.py +++ b/tests/viz_tests.py @@ -1081,6 +1081,7 @@ def test_filter_nulls(self, mock_uuid4): "comparator": "", "operator": "IS NOT NULL", "subject": "lat", + "isExtra": False, }, { "clause": "WHERE", @@ -1089,6 +1090,7 @@ def test_filter_nulls(self, mock_uuid4): "comparator": "", "operator": "IS NOT NULL", "subject": "lon", + "isExtra": False, }, ], "delimited_key": [ @@ -1099,6 +1101,7 @@ def test_filter_nulls(self, mock_uuid4): "comparator": "", "operator": "IS NOT NULL", "subject": "lonlat", + "isExtra": False, } ], "geohash_key": [ @@ -1109,6 +1112,7 @@ def test_filter_nulls(self, mock_uuid4): "comparator": "", "operator": "IS NOT NULL", "subject": "geo", + "isExtra": False, } ], } diff --git a/tox.ini b/tox.ini index 0a7dcfffaa9e..e37f488424f7 100644 --- a/tox.ini +++ b/tox.ini @@ -33,6 +33,14 @@ setenv = whitelist_externals = npm +[testenv:thumbnails] +setenv = + SUPERSET_CONFIG = tests.superset_test_config_thumbnails +deps = + -rrequirements.txt + -rrequirements-dev.txt + .[postgres] + [testenv:black] commands = black --check setup.py superset tests