diff --git a/.circleci/config.yml b/.circleci/config.yml index a18c644cb2..020186323e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,17 +1,5 @@ version: 2.0 -build-docker-image-job: &build-docker-image-job - docker: - - image: circleci/node:12 - steps: - - setup_remote_docker - - checkout - - run: sudo apt update - - run: sudo apt install python3-pip - - run: sudo pip3 install -r requirements_bundles.txt - - run: .circleci/update_version - - run: npm run bundle - - run: .circleci/docker_build jobs: backend-lint: docker: @@ -83,68 +71,74 @@ jobs: name: Run Visualizations Tests command: (cd viz-lib && npm test) - run: npm run lint - frontend-e2e-tests: - environment: - COMPOSE_FILE: .circleci/docker-compose.cypress.yml - COMPOSE_PROJECT_NAME: cypress - PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA== - CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th - CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx + build-docker-image: docker: - image: circleci/node:12 steps: - setup_remote_docker - checkout - - run: - name: Install npm dependencies - command: | - npm ci - - run: - name: Setup Redash server - command: | - npm run cypress build - npm run cypress start -- --skip-db-seed - docker-compose run cypress npm run cypress db-seed - - run: - name: Execute Cypress tests - command: npm run cypress run-ci - build-docker-image: *build-docker-image-job - build-preview-docker-image: *build-docker-image-job + - run: echo "export MOZILLA_VERSION=master" >> $BASH_ENV + - run: sudo apt update + - run: sudo apt install python3-pip + - run: sudo pip3 install -r requirements_bundles.txt + - run: .circleci/update_version + - run: npm run bundle + - run: .circleci/docker_build + build-docker-image-tag: + docker: + - image: circleci/node:12 + steps: + - setup_remote_docker + - checkout + - run: echo "export MOZILLA_VERSION=$CIRCLE_TAG" >> $BASH_ENV + - run: sudo apt update + - run: sudo apt install python3-pip + - run: sudo pip3 install -r requirements_bundles.txt + - run: .circleci/update_version + - run: npm run bundle + - run: .circleci/docker_build + # Create alias from tag to "latest": + - run: docker tag $DOCKERHUB_REPO:$CIRCLE_TAG $DOCKERHUB_REPO:latest + - run: docker push $DOCKERHUB_REPO:latest workflows: version: 2 build: jobs: - - backend-lint + - backend-lint: + filters: + tags: + only: /^m[0-9]+(\.[0-9]+)?$/ - backend-unit-tests: + filters: + tags: + only: /^m[0-9]+(\.[0-9]+)?$/ requires: - backend-lint - - frontend-lint + - frontend-lint: + filters: + tags: + only: /^m[0-9]+(\.[0-9]+)?$/ - frontend-unit-tests: + filters: + tags: + only: /^m[0-9]+(\.[0-9]+)?$/ requires: - backend-lint - frontend-lint - - frontend-e2e-tests: - requires: - - frontend-lint - - build-preview-docker-image: + - build-docker-image: requires: - backend-unit-tests - frontend-unit-tests - - frontend-e2e-tests filters: branches: only: - master - - hold: - type: approval + - build-docker-image-tag: requires: - backend-unit-tests - frontend-unit-tests - - frontend-e2e-tests filters: branches: - only: - - /release\/.*/ - - build-docker-image: - requires: - - hold + ignore: /.*/ + tags: + only: /^m[0-9]+(\.[0-9]+)?$/ diff --git a/.circleci/docker-compose.circle.yml b/.circleci/docker-compose.circle.yml index e756a92ff3..84ef76a823 100644 --- a/.circleci/docker-compose.circle.yml +++ b/.circleci/docker-compose.circle.yml @@ -1,4 +1,4 @@ -version: '3' +version: '2.2' services: redash: build: ../ diff --git a/.circleci/docker-compose.cypress.yml b/.circleci/docker-compose.cypress.yml index 9d3efe3cd8..f058d652e4 100644 --- a/.circleci/docker-compose.cypress.yml +++ b/.circleci/docker-compose.cypress.yml @@ -1,4 +1,4 @@ -version: '3' +version: '2.2' services: server: build: ../ @@ -14,6 +14,7 @@ services: REDASH_REDIS_URL: "redis://redis:6379/0" REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres" REDASH_RATELIMIT_ENABLED: "false" + REDASH_ENFORCE_CSRF: "true" scheduler: build: ../ command: scheduler diff --git a/.circleci/docker_build b/.circleci/docker_build index e105c584e0..b4da012ae8 100755 --- a/.circleci/docker_build +++ b/.circleci/docker_build @@ -1,17 +1,9 @@ #!/bin/bash -VERSION=$(jq -r .version package.json) -VERSION_TAG=$VERSION.b$CIRCLE_BUILD_NUM +VERSION_TAG="$MOZILLA_VERSION" docker login -u $DOCKER_USER -p $DOCKER_PASS -if [ $CIRCLE_BRANCH = master ] || [ $CIRCLE_BRANCH = preview-image ] -then - docker build --build-arg skip_dev_deps=true -t redash/redash:preview -t redash/preview:$VERSION_TAG . - docker push redash/redash:preview - docker push redash/preview:$VERSION_TAG -else - docker build --build-arg skip_dev_deps=true -t redash/redash:$VERSION_TAG . - docker push redash/redash:$VERSION_TAG -fi +docker build -t $DOCKERHUB_REPO:$VERSION_TAG . +docker push $DOCKERHUB_REPO:$VERSION_TAG -echo "Built: $VERSION_TAG" \ No newline at end of file +echo "Built: $VERSION_TAG" diff --git a/.circleci/update_version b/.circleci/update_version index d397fb23df..338517b939 100755 --- a/.circleci/update_version +++ b/.circleci/update_version @@ -1,6 +1,8 @@ #!/bin/bash +bin/dockerflow-version "$MOZILLA_VERSION" + VERSION=$(jq -r .version package.json) -FULL_VERSION=$VERSION+b$CIRCLE_BUILD_NUM +FULL_VERSION=$VERSION+b$CIRCLE_BUILD_NUM-$MOZILLA_VERSION sed -ri "s/^__version__ = '([A-Za-z0-9.-]*)'/__version__ = '$FULL_VERSION'/" redash/__init__.py sed -i "s/dev/$CIRCLE_SHA1/" client/app/version.json diff --git a/bin/bundle-extensions b/bin/bundle-extensions index a8c1e3cae1..ce0e300854 100755 --- a/bin/bundle-extensions +++ b/bin/bundle-extensions @@ -6,8 +6,8 @@ from pathlib import Path from shutil import copy from collections import OrderedDict as odict -from importlib_metadata import entry_points -from importlib_resources import contents, is_resource, path +import importlib_metadata +import importlib_resources # Name of the subdirectory BUNDLE_DIRECTORY = "bundle" @@ -25,18 +25,6 @@ if not extensions_directory.exists(): os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path) -def resource_isdir(module, resource): - """Whether a given resource is a directory in the given module - - https://importlib-resources.readthedocs.io/en/latest/migration.html#pkg-resources-resource-isdir - """ - try: - return resource in contents(module) and not is_resource(module, resource) - except (ImportError, TypeError): - # module isn't a package, so can't have a subdirectory/-package - return False - - def entry_point_module(entry_point): """Returns the dotted module path for the given entry point""" return entry_point.pattern.match(entry_point.value).group("module") @@ -77,18 +65,28 @@ def load_bundles(): """ bundles = odict() - for entry_point in entry_points().get("redash.bundles", []): + for entry_point in importlib_metadata.entry_points().get("redash.bundles", []): logger.info('Loading Redash bundle "%s".', entry_point.name) module = entry_point_module(entry_point) # Try to get a list of bundle files - if not resource_isdir(module, BUNDLE_DIRECTORY): + try: + bundle_dir = importlib_resources.files(module).joinpath(BUNDLE_DIRECTORY) + except (ImportError, TypeError): + # Module isn't a package, so can't have a subdirectory/-package logger.error( - 'Redash bundle directory "%s" could not be found.', entry_point.name + 'Redash bundle module "%s" could not be imported: "%s"', + entry_point.name, + module, ) continue - with path(module, BUNDLE_DIRECTORY) as bundle_dir: - bundles[entry_point.name] = list(bundle_dir.rglob("*")) - + if not bundle_dir.is_dir(): + logger.error( + 'Redash bundle directory "%s" could not be found or is not a directory: "%s"', + entry_point.name, + bundle_dir, + ) + continue + bundles[entry_point.name] = list(bundle_dir.rglob("*")) return bundles diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index eb112c8412..91b2fd6e52 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -2,33 +2,38 @@ set -e scheduler() { + /app/manage.py db upgrade echo "Starting RQ scheduler..." exec /app/manage.py rq scheduler } dev_scheduler() { + /app/manage.py db upgrade echo "Starting dev RQ scheduler..." exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq scheduler } worker() { + /app/manage.py db upgrade echo "Starting RQ worker..." export WORKERS_COUNT=${WORKERS_COUNT:-2} export QUEUES=${QUEUES:-} - + supervisord -c worker.conf } dev_worker() { + /app/manage.py db upgrade echo "Starting dev RQ worker..." exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq worker $QUEUES } server() { + /app/manage.py db upgrade # Recycle gunicorn workers every n-th request. See http://docs.gunicorn.org/en/stable/settings.html#max-requests for more details. MAX_REQUESTS=${MAX_REQUESTS:-1000} MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100} @@ -126,4 +131,3 @@ case "$1" in exec "$@" ;; esac - diff --git a/bin/dockerflow-version b/bin/dockerflow-version new file mode 100755 index 0000000000..027d61971f --- /dev/null +++ b/bin/dockerflow-version @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eo pipefail + +VERSION="$1" + +printf '{"commit":"%s","version":"%s","source":"https://github.com/%s/%s","build":"%s"}\n' \ + "$CIRCLE_SHA1" \ + "$VERSION" \ + "$CIRCLE_PROJECT_USERNAME" \ + "$CIRCLE_PROJECT_REPONAME" \ + "$CIRCLE_BUILD_URL" \ +> version.json diff --git a/bin/migrations-graph b/bin/migrations-graph new file mode 100755 index 0000000000..5998d4233d --- /dev/null +++ b/bin/migrations-graph @@ -0,0 +1,83 @@ +#!/usr/bin/env python +""" +A quick helper script to print the Alembic migration history +via Graphiz and show it via GraphvizOnline on +https://dreampuf.github.io/GraphvizOnline/. + +This requires the Graphviz Python library: + + $ pip install --user graphviz + +Then run it with the path to the Alembic config file: + + $ migrations-graph --config migrations/alembic.ini + +""" +import os +import sys +import urllib.parse +import urllib.request + +import click +from alembic import util +from alembic.script import ScriptDirectory +from alembic.config import Config +from alembic.util import CommandError +from graphviz import Digraph + +# Make sure redash can be imported in the migration files +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + + +def get_revisions(config, rev_range=None): + script = ScriptDirectory.from_config(config) + + if rev_range is not None: + if ":" not in rev_range: + raise util.CommandError( + "History range requires [start]:[end], [start]:, or :[end]" + ) + base, head = rev_range.strip().split(":") + else: + base = head = None + + return script.walk_revisions(base=base or "base", head=head or "heads") + + +def generate_revision_graph(revisions): + dot = Digraph() + for revision in revisions: + dot.node(revision.revision) + if revision.down_revision is None: + dot.edge("base", revision.revision) + continue + if isinstance(revision.down_revision, str): + dot.edge(revision.down_revision, revision.revision) + continue + for down_revision in revision.down_revision: + dot.edge(down_revision, revision.revision) + return dot + + +@click.command() +@click.option("--config", default="alembic.ini", help="path to alembic config file") +@click.option("--name", default="alembic", help="name of the alembic ini section") +def cli(config, name): + """ + Generates a simple Graphviz dot file and creates a link to + view it online via https://dreampuf.github.io/GraphvizOnline/. + """ + alembic_config = Config(file_=config, ini_section=name) + try: + revisions = get_revisions(alembic_config) + except CommandError as e: + sys.exit(e) + + dot = generate_revision_graph(revisions) + encoded_dot = urllib.parse.quote(bytes(dot.source, "utf-8")) + viz_url = "https://dreampuf.github.io/GraphvizOnline/#%s" % encoded_dot + print("Generated graph for migration history in %s: %s " % (config, viz_url)) + + +if __name__ == "__main__": + cli() diff --git a/client/.babelrc b/client/.babelrc index 0fe25a043c..af5a043b4b 100644 --- a/client/.babelrc +++ b/client/.babelrc @@ -1,19 +1,24 @@ { "presets": [ - ["@babel/preset-env", { - "exclude": [ - "@babel/plugin-transform-async-to-generator", - "@babel/plugin-transform-arrow-functions" - ], - "useBuiltIns": "usage" - }], - "@babel/preset-react" + [ + "@babel/preset-env", + { + "exclude": ["@babel/plugin-transform-async-to-generator", "@babel/plugin-transform-arrow-functions"], + "corejs": "2", + "useBuiltIns": "usage" + } + ], + "@babel/preset-react", + "@babel/preset-typescript" ], "plugins": [ "@babel/plugin-proposal-class-properties", "@babel/plugin-transform-object-assign", - ["babel-plugin-transform-builtin-extend", { - "globals": ["Error"] - }] + [ + "babel-plugin-transform-builtin-extend", + { + "globals": ["Error"] + } + ] ] } diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 152bf9ca3d..8bc0055d03 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -1,17 +1,40 @@ module.exports = { root: true, - extends: ["react-app", "plugin:compat/recommended", "prettier"], - plugins: ["jest", "compat", "no-only-tests"], + parser: "@typescript-eslint/parser", + extends: [ + "react-app", + "plugin:compat/recommended", + "prettier", + // Remove any typescript-eslint rules that would conflict with prettier + "prettier/@typescript-eslint", + ], + plugins: ["jest", "compat", "no-only-tests", "@typescript-eslint"], settings: { - "import/resolver": "webpack" + "import/resolver": "webpack", }, env: { browser: true, - node: true + node: true, }, rules: { // allow debugger during development "no-debugger": process.env.NODE_ENV === "production" ? 2 : 0, "jsx-a11y/anchor-is-valid": "off", - } + }, + overrides: [ + { + // Only run typescript-eslint on TS files + files: ["*.ts", "*.tsx", ".*.ts", ".*.tsx"], + extends: ["plugin:@typescript-eslint/recommended"], + rules: { + // Do not require functions (especially react components) to have explicit returns + "@typescript-eslint/explicit-function-return-type": "off", + // Do not require to type every import from a JS file to speed up development + "@typescript-eslint/no-explicit-any": "off", + // Do not complain about useless contructors in declaration files + "no-useless-constructor": "off", + "@typescript-eslint/no-useless-constructor": "error", + }, + }, + ], }; diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index e95084039b..37c8c3b84d 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -16,7 +16,6 @@ @import "~antd/lib/pagination/style/index"; @import "~antd/lib/table/style/index"; @import "~antd/lib/popover/style/index"; -@import "~antd/lib/icon/style/index"; @import "~antd/lib/tag/style/index"; @import "~antd/lib/grid/style/index"; @import "~antd/lib/switch/style/index"; @@ -31,6 +30,7 @@ @import "~antd/lib/badge/style/index"; @import "~antd/lib/card/style/index"; @import "~antd/lib/spin/style/index"; +@import "~antd/lib/skeleton/style/index"; @import "~antd/lib/tabs/style/index"; @import "~antd/lib/notification/style/index"; @import "~antd/lib/collapse/style/index"; @@ -401,3 +401,14 @@ .@{checkbox-prefix-cls} + span { padding-right: 0; } + +// make sure Multiple select has room for icons +.@{select-prefix-cls}-multiple { + &.@{select-prefix-cls}-show-arrow, + &.@{select-prefix-cls}-show-search, + &.@{select-prefix-cls}-loading { + .@{select-prefix-cls}-selector { + padding-right: 30px; + } + } +} diff --git a/client/app/assets/less/inc/alert.less b/client/app/assets/less/inc/alert.less index 3e73d9b54d..fc1f1bbb61 100755 --- a/client/app/assets/less/inc/alert.less +++ b/client/app/assets/less/inc/alert.less @@ -23,6 +23,10 @@ padding: 5px 8px; } + .ant-form-item-explain { + margin-top: 10px; + } + .alert-last-triggered { color: @headings-color; } diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index 2da99f12d6..35933634d7 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -78,8 +78,6 @@ strong { } } -// Fixed width layout for specific pages - .settings-screen, .home-page, .page-dashboard-list, @@ -89,7 +87,7 @@ strong { .admin-page-layout { .container { width: 100%; - max-width: 1200px; + max-width: none; } } @@ -116,6 +114,10 @@ strong { transition: height 0s, width 0s !important; } +.admin-schema-editor { + padding: 50px 0; +} + .bg-ace { background-color: fade(@redash-gray, 12%) !important; } diff --git a/client/app/assets/less/inc/popover.less b/client/app/assets/less/inc/popover.less index 5fcad7089b..c687a089a2 100755 --- a/client/app/assets/less/inc/popover.less +++ b/client/app/assets/less/inc/popover.less @@ -1,5 +1,7 @@ .popover { box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px; + color: #000000; + z-index: 1000000001; // So that it can popover a dropdown menu } .popover-title { @@ -19,4 +21,4 @@ p { margin-bottom: 0; } -} \ No newline at end of file +} diff --git a/client/app/assets/less/inc/schema-browser.less b/client/app/assets/less/inc/schema-browser.less index 2f25f78665..3fdffad9e1 100644 --- a/client/app/assets/less/inc/schema-browser.less +++ b/client/app/assets/less/inc/schema-browser.less @@ -8,14 +8,14 @@ div.table-name { position: relative; height: 22px; - .copy-to-editor { + .copy-to-editor, .info { display: none; } &:hover { background: fade(@redash-gray, 10%); - .copy-to-editor { + .copy-to-editor, .info { display: flex; } } @@ -45,7 +45,7 @@ div.table-name { background: transparent; } - .copy-to-editor { + .copy-to-editor, .info { color: fade(@redash-gray, 90%); cursor: pointer; position: absolute; @@ -58,6 +58,10 @@ div.table-name { justify-content: center; } + .info { + right: 20px + } + .table-open { padding: 0 22px 0 26px; overflow: hidden; @@ -66,14 +70,21 @@ div.table-name { position: relative; height: 18px; - .copy-to-editor { + .column-type { + color: fade(@text-color, 80%); + font-size: 10px; + margin-left: 2px; + text-transform: uppercase; + } + + .copy-to-editor, .info { display: none; } &:hover { background: fade(@redash-gray, 10%); - .copy-to-editor { + .copy-to-editor, .info { display: flex; } } diff --git a/client/app/assets/less/inc/table.less b/client/app/assets/less/inc/table.less index 7a43a6f9e6..9b562f1f01 100755 --- a/client/app/assets/less/inc/table.less +++ b/client/app/assets/less/inc/table.less @@ -1,149 +1,153 @@ .table { - margin-bottom: 0; - - th.sortable-column { - cursor: pointer; - } - - &:not(.table-striped) > thead > tr > th { - background-color: #FAFAFA; - } - - [class*="bg-"] { - & > tr > th { - color: #fff; - border-bottom: 0; - background: transparent !important; - } - - & + tbody > tr:first-child > td { - border-top: 0; - } - } - - & > thead > tr > th { - vertical-align: middle; - font-weight: 500; - color: #333; - border-width: 1px; - text-transform: uppercase; - padding: 15px 10px; + margin-bottom: 0; + + th.sortable-column { + cursor: pointer; + } + + &:not(.table-striped) > thead > tr > th { + background-color: #fafafa; + } + + [class*="bg-"] { + & > tr > th { + color: #fff; + border-bottom: 0; + background: transparent !important; } - - & > thead > tr, - & > tbody > tr, - & > tfoot > tr { - - & > th, & > td { - - &:first-child { - padding-left: 30px; - } - - &:last-child { - padding-right: 30px; - } - - } + + & + tbody > tr:first-child > td { + border-top: 0; } - - tbody > tr:last-child > td { - padding-bottom: 20px; + } + + & > thead > tr > th { + vertical-align: middle; + font-weight: 500; + color: #333; + border-width: 1px; + text-transform: uppercase; + padding: 15px 10px; + } + + & > thead > tr, + & > tbody > tr, + & > tfoot > tr { + & > th, + & > td { + &:first-child { + padding-left: 30px; + } + + &:last-child { + padding-right: 30px; + } } + } + + tbody > tr:last-child > td { + padding-bottom: 20px; + } } .table-bordered { - border: 0; - - & > tbody > tr { - & > td, & > th { - border-bottom: 0; - border-left: 0; - - &:last-child { - border-right: 0; - } - } + border: 0; + + & > tbody > tr { + & > td, + & > th { + border-bottom: 0; + border-left: 0; + + &:last-child { + border-right: 0; + } } - - & > thead > tr > th { - border-left: 0; - - &:last-child { - border-right: 0; - } + } + + & > thead > tr > th { + border-left: 0; + + &:last-child { + border-right: 0; } + } } .table-vmiddle { - td { - vertical-align: middle !important; - } + td { + vertical-align: middle !important; + } } .table-responsive { - border: 0; + border: 0; } -.tile .table { - - & > thead:not([class*="bg-"]) > tr > th { - border-top: 1px solid @table-border-color; - - } +.tile .table { + & > thead:not([class*="bg-"]) > tr > th { + border-top: 1px solid @table-border-color; + } } .table-hover > tbody > tr:hover { - background-color: #f4f4f4; + background-color: #f4f4f4; } .table-data { - tbody > tr > td { - padding-top: 5px !important; - } - - .btn-favourite, .btn-archive { - font-size: 15px; - } + thead > tr > th { + white-space: nowrap; + } + + tbody > tr > td { + padding-top: 5px !important; + } + + .btn-favourite, + .btn-archive { + font-size: 15px; + } } .table-main-title { - font-weight: 500; - line-height: 1.7 !important; + font-weight: 500; + line-height: 1.7 !important; } .btn-favourite { - color: #d4d4d4; - transition: all .25s ease-in-out; - - &:hover, &:focus { - color: @yellow-darker; - cursor: pointer; - } - - .fa-star { - color: @yellow-darker; - } + color: #d4d4d4; + transition: all 0.25s ease-in-out; + + &:hover, + &:focus { + color: @yellow-darker; + cursor: pointer; + } + + .fa-star { + color: @yellow-darker; + } } .btn-archive { - color: #d4d4d4; - transition: all .25s ease-in-out; - - &:hover, &:focus { - color: @gray-light; - } - - .fa-archive { - color: @gray-light; - } + color: #d4d4d4; + transition: all 0.25s ease-in-out; + + &:hover, + &:focus { + color: @gray-light; + } + + .fa-archive { + color: @gray-light; + } } .table > thead > tr > th { - text-transform: none; + text-transform: none; } .table-data .label-tag { - display: inline-block; - max-width: 135px; - } \ No newline at end of file + display: inline-block; + max-width: 135px; +} diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index f65d3bbc7c..ab84515856 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -493,3 +493,17 @@ nav .rg-bottom { padding-right: 0; } } + +.ui-select-choices-row .info { + display: none; +} + +.ui-select-choices-row { + &:hover { + .info { + cursor: pointer; + width: 20px; + display: inline; + } + } +} diff --git a/client/app/components/ApplicationArea/ApplicationLayout/DesktopNavbar.jsx b/client/app/components/ApplicationArea/ApplicationLayout/DesktopNavbar.jsx index 2f068e1279..5cb8a8cfea 100644 --- a/client/app/components/ApplicationArea/ApplicationLayout/DesktopNavbar.jsx +++ b/client/app/components/ApplicationArea/ApplicationLayout/DesktopNavbar.jsx @@ -2,13 +2,21 @@ import { first } from "lodash"; import React, { useState } from "react"; import Button from "antd/lib/button"; import Menu from "antd/lib/menu"; -import Icon from "antd/lib/icon"; import HelpTrigger from "@/components/HelpTrigger"; import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog"; import { Auth, currentUser } from "@/services/auth"; import settingsMenu from "@/services/settingsMenu"; import logoUrl from "@/assets/images/redash_icon_small.png"; +import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined"; +import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined"; +import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined"; +import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined"; +import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined"; +import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined"; +import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined"; +import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined"; + import VersionInfo from "./VersionInfo"; import "./DesktopNavbar.less"; @@ -46,7 +54,7 @@ export default function DesktopNavbar() { {currentUser.hasPermission("list_dashboards") && ( - + Dashboards @@ -54,7 +62,7 @@ export default function DesktopNavbar() { {currentUser.hasPermission("view_query") && ( - + Queries @@ -62,7 +70,7 @@ export default function DesktopNavbar() { {currentUser.hasPermission("list_alerts") && ( - + Alerts @@ -78,7 +86,7 @@ export default function DesktopNavbar() { title={ - + Create @@ -111,14 +119,14 @@ export default function DesktopNavbar() { - + Help {firstSettingsTab && ( - + Settings @@ -158,7 +166,7 @@ export default function DesktopNavbar() { ); diff --git a/client/app/components/ApplicationArea/ApplicationLayout/MobileNavbar.jsx b/client/app/components/ApplicationArea/ApplicationLayout/MobileNavbar.jsx index be5e2f0be5..20982b0297 100644 --- a/client/app/components/ApplicationArea/ApplicationLayout/MobileNavbar.jsx +++ b/client/app/components/ApplicationArea/ApplicationLayout/MobileNavbar.jsx @@ -2,7 +2,7 @@ import { first } from "lodash"; import React from "react"; import PropTypes from "prop-types"; import Button from "antd/lib/button"; -import Icon from "antd/lib/icon"; +import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined"; import Dropdown from "antd/lib/dropdown"; import Menu from "antd/lib/menu"; import { Auth, currentUser } from "@/services/auth"; @@ -70,7 +70,7 @@ export default function MobileNavbar({ getPopupContainer }) { }> diff --git a/client/app/components/ApplicationArea/Router.jsx b/client/app/components/ApplicationArea/Router.jsx index 7fdb39d461..e131bc06ff 100644 --- a/client/app/components/ApplicationArea/Router.jsx +++ b/client/app/components/ApplicationArea/Router.jsx @@ -1,5 +1,5 @@ import { isFunction, startsWith, trimStart, trimEnd } from "lodash"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useContext } from "react"; import PropTypes from "prop-types"; import UniversalRouter from "universal-router"; import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary"; @@ -14,6 +14,12 @@ function generateRouteKey() { .substr(2); } +export const CurrentRouteContext = React.createContext(null); + +export function useCurrentRoute() { + return useContext(CurrentRouteContext); +} + export function stripBase(href) { // Resolve provided link and '' (root) relative to document's base. // If provided href is not related to current document (does not @@ -53,7 +59,7 @@ export default function Router({ routes, onRouteChange }) { errorHandlerRef.current.reset(); } - const pathname = stripBase(location.path); + const pathname = stripBase(location.path) || "/"; // This is a optimization for route resolver: if current route was already resolved // from this path - do nothing. It also prevents router from using outdated route in a case @@ -109,9 +115,11 @@ export default function Router({ routes, onRouteChange }) { } return ( - }> - {currentRoute.render(currentRoute)} - + + }> + {currentRoute.render(currentRoute)} + + ); } diff --git a/client/app/components/ApplicationArea/routeWithUserSession.jsx b/client/app/components/ApplicationArea/routeWithUserSession.jsx index 812a0ac34b..7c3ec86bd0 100644 --- a/client/app/components/ApplicationArea/routeWithUserSession.jsx +++ b/client/app/components/ApplicationArea/routeWithUserSession.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary"; import { Auth } from "@/services/auth"; +import { policy } from "@/services/policy"; import organizationStatus from "@/services/organizationStatus"; import ApplicationLayout from "./ApplicationLayout"; import ErrorMessage from "./ErrorMessage"; @@ -17,7 +18,7 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) { useEffect(() => { let isCancelled = false; - Promise.all([Auth.requireSession(), organizationStatus.refresh()]) + Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()]) .then(() => { if (!isCancelled) { setIsAuthenticated(!!Auth.isAuthenticated()); diff --git a/client/app/components/CodeBlock.jsx b/client/app/components/CodeBlock.jsx index b2947894e9..13a643dc19 100644 --- a/client/app/components/CodeBlock.jsx +++ b/client/app/components/CodeBlock.jsx @@ -2,6 +2,7 @@ import React from "react"; import PropTypes from "prop-types"; import Button from "antd/lib/button"; import Tooltip from "antd/lib/tooltip"; +import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined"; import "./CodeBlock.less"; export default class CodeBlock extends React.Component { @@ -59,7 +60,7 @@ export default class CodeBlock extends React.Component { const copyButton = ( - , , ] diff --git a/client/app/components/DialogWrapper.d.ts b/client/app/components/DialogWrapper.d.ts new file mode 100644 index 0000000000..a5cee81b22 --- /dev/null +++ b/client/app/components/DialogWrapper.d.ts @@ -0,0 +1,30 @@ +import { ModalProps } from "antd/lib/modal/Modal"; + +export interface DialogProps { + props: ModalProps; + close: (result: ROk) => void; + dismiss: (result: RCancel) => void; +} + +export type DialogWrapperChildProps = { + dialog: DialogProps; +}; + +export type DialogComponentType = React.ComponentType< + DialogWrapperChildProps & P +>; + +export function wrap( + DialogComponent: DialogComponentType +): { + Component: DialogComponentType; + showModal: ( + props?: P + ) => { + update: (props: P) => void; + onClose: (handler: (result: ROk) => Promise) => void; + onDismiss: (handler: (result: RCancel) => Promise) => void; + close: (result: ROk) => void; + dismiss: (result: RCancel) => void; + }; +}; diff --git a/client/app/components/EditParameterSettingsDialog.jsx b/client/app/components/EditParameterSettingsDialog.jsx index 59673c0ea1..187f72bfa5 100644 --- a/client/app/components/EditParameterSettingsDialog.jsx +++ b/client/app/components/EditParameterSettingsDialog.jsx @@ -100,7 +100,7 @@ function EditParameterSettingsDialog(props) { return true; } - function onConfirm(e) { + function onConfirm() { // update title to default if (!param.title) { // forced to do this cause param won't update in time for save @@ -109,8 +109,6 @@ function EditParameterSettingsDialog(props) { } props.dialog.close(param); - - e.preventDefault(); // stops form redirect } return ( @@ -132,7 +130,7 @@ function EditParameterSettingsDialog(props) { {isNew ? "Add Parameter" : "OK"} , ]}> -
+ {isNew && ( props.openAddToDashboardForm(props.selectedTab)}> - Add to Dashboard + Add to Dashboard )} {!props.query.isNew() && ( props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton"> - Embed Elsewhere + Embed Elsewhere )} @@ -32,7 +38,7 @@ export default function QueryControlDropdown(props) { queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}> - Download as CSV File + Download as CSV File @@ -43,7 +49,7 @@ export default function QueryControlDropdown(props) { queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}> - Download as TSV File + Download as TSV File @@ -54,16 +60,22 @@ export default function QueryControlDropdown(props) { queryResult={props.queryResult} embed={props.embed} apiKey={props.apiKey}> - Download as Excel File + Download as Excel File + ); return ( ); diff --git a/client/app/components/EditVisualizationButton/index.jsx b/client/app/components/EditVisualizationButton/index.jsx index 0311d4fd72..822150da5c 100644 --- a/client/app/components/EditVisualizationButton/index.jsx +++ b/client/app/components/EditVisualizationButton/index.jsx @@ -1,7 +1,7 @@ import React from "react"; import PropTypes from "prop-types"; import Button from "antd/lib/button"; -import Icon from "antd/lib/icon"; +import FormOutlinedIcon from "@ant-design/icons/FormOutlined"; export default function EditVisualizationButton(props) { return ( @@ -9,7 +9,7 @@ export default function EditVisualizationButton(props) { data-test="EditVisualization" className="edit-visualization" onClick={() => props.openVisualizationEditor(props.selectedTab)}> - + Edit Visualization ); diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx index 18c3872432..64fe747b60 100644 --- a/client/app/components/HelpTrigger.jsx +++ b/client/app/components/HelpTrigger.jsx @@ -4,7 +4,7 @@ import PropTypes from "prop-types"; import cx from "classnames"; import Tooltip from "antd/lib/tooltip"; import Drawer from "antd/lib/drawer"; -import Icon from "antd/lib/icon"; +import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined"; import BigMessage from "@/components/BigMessage"; import DynamicComponent from "@/components/DynamicComponent"; @@ -174,7 +174,7 @@ export default class HelpTrigger extends React.Component { )} - + diff --git a/client/app/components/InputWithCopy.jsx b/client/app/components/InputWithCopy.jsx index f2e5121502..85360e5df6 100644 --- a/client/app/components/InputWithCopy.jsx +++ b/client/app/components/InputWithCopy.jsx @@ -1,6 +1,6 @@ import React from "react"; import Input from "antd/lib/input"; -import Icon from "antd/lib/icon"; +import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined"; import Tooltip from "antd/lib/tooltip"; export default class InputWithCopy extends React.Component { @@ -42,7 +42,7 @@ export default class InputWithCopy extends React.Component { render() { const copyButton = ( - + ); diff --git a/client/app/components/Paginator.jsx b/client/app/components/Paginator.jsx index 99589e843c..945ba34b81 100644 --- a/client/app/components/Paginator.jsx +++ b/client/app/components/Paginator.jsx @@ -2,24 +2,38 @@ import React from "react"; import PropTypes from "prop-types"; import Pagination from "antd/lib/pagination"; -export default function Paginator({ page, itemsPerPage, totalCount, onChange }) { - if (totalCount <= itemsPerPage) { +const MIN_ITEMS_PER_PAGE = 5; + +export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) { + if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) { return null; } return (
- + onPageSizeChange(size)} + defaultCurrent={page} + pageSize={pageSize} + total={totalCount} + onChange={onChange} + />
); } Paginator.propTypes = { page: PropTypes.number.isRequired, - itemsPerPage: PropTypes.number.isRequired, + showPageSizeSelect: PropTypes.bool, + pageSize: PropTypes.number.isRequired, totalCount: PropTypes.number.isRequired, + onPageSizeChange: PropTypes.func, onChange: PropTypes.func, }; Paginator.defaultProps = { + showPageSizeSelect: false, onChange: () => {}, + onPageSizeChange: () => {}, }; diff --git a/client/app/components/ParameterMappingInput.jsx b/client/app/components/ParameterMappingInput.jsx index 1ed41f1b65..d3f5d7de65 100644 --- a/client/app/components/ParameterMappingInput.jsx +++ b/client/app/components/ParameterMappingInput.jsx @@ -8,7 +8,6 @@ import Select from "antd/lib/select"; import Table from "antd/lib/table"; import Popover from "antd/lib/popover"; import Button from "antd/lib/button"; -import Icon from "antd/lib/icon"; import Tag from "antd/lib/tag"; import Input from "antd/lib/input"; import Radio from "antd/lib/radio"; @@ -19,6 +18,11 @@ import { ParameterMappingType } from "@/services/widget"; import { Parameter, cloneParameter } from "@/services/parameters"; import HelpTrigger from "@/components/HelpTrigger"; +import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled"; +import EditOutlinedIcon from "@ant-design/icons/EditOutlined"; +import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined"; +import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined"; + import "./ParameterMappingInput.less"; const { Option } = Select; @@ -181,7 +185,7 @@ export class ParameterMappingInput extends React.Component { Existing dashboard parameter{" "} {noExisting ? ( - + ) : null} @@ -355,7 +359,7 @@ class MappingEditor extends React.Component { visible={visible} onVisibleChange={this.onVisibleChange}> ); @@ -434,10 +438,10 @@ class TitleEditor extends React.Component { autoFocus /> ); @@ -460,7 +464,7 @@ class TitleEditor extends React.Component { visible={this.state.showPopup} onVisibleChange={this.onPopupVisibleChange}> ); diff --git a/client/app/components/ParameterValueInput.less b/client/app/components/ParameterValueInput.less index 9921c74a94..de248daa71 100644 --- a/client/app/components/ParameterValueInput.less +++ b/client/app/components/ParameterValueInput.less @@ -1,4 +1,4 @@ -@import '~antd/lib/input-number/style/index'; // for ant @vars +@import "~antd/lib/input-number/style/index"; // for ant @vars @input-dirty: #fffce1; @@ -17,9 +17,10 @@ } &[data-dirty] { - .@{ant-prefix}-input, // covers also ant date component + .@{ant-prefix}-input, .@{ant-prefix}-input-number, - .@{ant-prefix}-select-selection { + .@{ant-prefix}-select-selector, + .@{ant-prefix}-picker { background-color: @input-dirty; } } diff --git a/client/app/components/PreviewCard.jsx b/client/app/components/PreviewCard.jsx index 9b1fcb099b..87b647310b 100644 --- a/client/app/components/PreviewCard.jsx +++ b/client/app/components/PreviewCard.jsx @@ -68,7 +68,7 @@ UserPreviewCard.defaultProps = { // DataSourcePreviewCard export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) { - const imageUrl = `/static/images/db-logos/${dataSource.type}.png`; + const imageUrl = `static/images/db-logos/${dataSource.type}.png`; const title = withLink ? {dataSource.name} : dataSource.name; return ( diff --git a/client/app/components/admin/Layout.jsx b/client/app/components/admin/Layout.jsx index 034c4917a9..faa675ccd6 100644 --- a/client/app/components/admin/Layout.jsx +++ b/client/app/components/admin/Layout.jsx @@ -1,6 +1,6 @@ import React from "react"; import PropTypes from "prop-types"; -import Tabs from "antd/lib/tabs"; +import Menu from "antd/lib/menu"; import PageHeader from "@/components/PageHeader"; import "./layout.less"; @@ -10,19 +10,19 @@ export default function Layout({ activeTab, children }) {
-
- - System Status}> - {activeTab === "system_status" ? children : null} - - RQ Status}> - {activeTab === "jobs" ? children : null} - - Outdated Queries}> - {activeTab === "outdated_queries" ? children : null} - - + + + System Status + + + RQ Status + + + Outdated Queries + + + {children}
diff --git a/client/app/components/admin/layout.less b/client/app/components/admin/layout.less index 48cf38463f..57f8ad81d1 100644 --- a/client/app/components/admin/layout.less +++ b/client/app/components/admin/layout.less @@ -1,17 +1,5 @@ .admin-page-layout { - &-tabs.ant-tabs { - > .ant-tabs-bar { - margin: 0; - - .ant-tabs-tab { - padding: 0; - - a { - display: inline-block; - padding: 12px 16px; - color: inherit; - } - } - } + .ant-table { + overflow-x: auto; } } diff --git a/client/app/components/dashboards/CreateDashboardDialog.jsx b/client/app/components/dashboards/CreateDashboardDialog.jsx index 0993e89721..cb6bb8caa9 100644 --- a/client/app/components/dashboards/CreateDashboardDialog.jsx +++ b/client/app/components/dashboards/CreateDashboardDialog.jsx @@ -1,6 +1,5 @@ import { trim } from "lodash"; import React, { useState } from "react"; -import { axios } from "@/services/axios"; import Modal from "antd/lib/modal"; import Input from "antd/lib/input"; import DynamicComponent from "@/components/DynamicComponent"; @@ -8,6 +7,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import navigateTo from "@/components/ApplicationArea/navigateTo"; import recordEvent from "@/services/recordEvent"; import { policy } from "@/services/policy"; +import { Dashboard } from "@/services/dashboard"; function CreateDashboardDialog({ dialog }) { const [name, setName] = useState(""); @@ -25,9 +25,9 @@ function CreateDashboardDialog({ dialog }) { if (name !== "") { setSaveInProgress(true); - axios.post("api/dashboards", { name }).then(data => { + Dashboard.save({ name }).then(data => { dialog.close(); - navigateTo(`dashboard/${data.slug}?edit`); + navigateTo(`${data.url}?edit`); }); recordEvent("create", "dashboard"); } diff --git a/client/app/components/dashboards/dashboard-grid.less b/client/app/components/dashboards/dashboard-grid.less index 572edbe933..2461b6aab3 100644 --- a/client/app/components/dashboards/dashboard-grid.less +++ b/client/app/components/dashboards/dashboard-grid.less @@ -113,15 +113,36 @@ overflow: hidden; } - .counter-visualization-content { - position: absolute; - left: 10px; - top: 15px; - right: 10px; - bottom: 15px; - height: auto; - overflow: hidden; - padding: 0; + .counter-visualization-container { + height: 100%; + + .counter-visualization-content { + position: absolute; + left: 10px; + top: 15px; + right: 10px; + bottom: 15px; + height: auto; + overflow: hidden; + padding: 0; + } + } +} + +.query-fixed-layout { + .visualization-renderer > .visualization-renderer-wrapper { + .counter-visualization-container { + // counter is too large on Query pages, so let's add some constraints + max-width: 600px; + max-height: 400px; + // center it + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: auto; + } } } diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index fe455e33d0..933889198d 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -1,24 +1,29 @@ -import React from "react"; +import React, { useState, useReducer, useCallback } from "react"; import PropTypes from "prop-types"; import cx from "classnames"; import Form from "antd/lib/form"; -import Input from "antd/lib/input"; -import InputNumber from "antd/lib/input-number"; -import Checkbox from "antd/lib/checkbox"; import Button from "antd/lib/button"; -import Upload from "antd/lib/upload"; -import Icon from "antd/lib/icon"; -import { includes, isFunction, filter, difference, isEmpty } from "lodash"; -import Select from "antd/lib/select"; +import { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash"; import notification from "@/services/notification"; import Collapse from "@/components/Collapse"; -import AceEditorInput from "@/components/AceEditorInput"; -import { toHuman } from "@/lib/utils"; -import { Field, Action, AntdForm } from "../proptypes"; +import DynamicFormField, { FieldType } from "./DynamicFormField"; +import getFieldLabel from "./getFieldLabel"; import helper from "./dynamicFormHelper"; import "./DynamicForm.less"; +const ActionType = PropTypes.shape({ + name: PropTypes.string.isRequired, + callback: PropTypes.func.isRequired, + type: PropTypes.string, + pullRight: PropTypes.bool, + disabledWhenDirty: PropTypes.bool, +}); + +const AntdFormType = PropTypes.shape({ + validateFieldsAndScroll: PropTypes.func, +}); + const fieldRules = ({ type, required, minLength }) => { const requiredRule = required; const minLengthRule = minLength && includes(["text", "email", "password"], type); @@ -31,282 +36,206 @@ const fieldRules = ({ type, required, minLength }) => { ].filter(rule => rule); }; -class DynamicForm extends React.Component { - static propTypes = { - id: PropTypes.string, - fields: PropTypes.arrayOf(Field), - actions: PropTypes.arrayOf(Action), - feedbackIcons: PropTypes.bool, - hideSubmitButton: PropTypes.bool, - defaultShowExtraFields: PropTypes.bool, - saveText: PropTypes.string, - onSubmit: PropTypes.func, - form: AntdForm.isRequired, - }; - - static defaultProps = { - id: null, - fields: [], - actions: [], - feedbackIcons: false, - hideSubmitButton: false, - defaultShowExtraFields: false, - saveText: "Save", - onSubmit: () => {}, - }; - - constructor(props) { - super(props); - - const inProgressActions = {}; - props.actions.forEach(action => (inProgressActions[action.name] = false)); - - this.state = { - isSubmitting: false, - showExtraFields: props.defaultShowExtraFields, - inProgressActions, - }; - - this.actionCallbacks = this.props.actions.reduce( - (acc, cur) => ({ - ...acc, - [cur.name]: cur.callback, - }), - null - ); - } - - setActionInProgress = (actionName, inProgress) => { - this.setState(prevState => ({ - inProgressActions: { - ...prevState.inProgressActions, - [actionName]: inProgress, - }, - })); - }; - - handleSubmit = e => { - this.setState({ isSubmitting: true }); - e.preventDefault(); - - this.props.form.validateFieldsAndScroll((err, values) => { - Object.entries(values).forEach(([key, value]) => { - const initialValue = this.props.fields.find(f => f.name === key).initialValue; - if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") { - values[key] = null; - } - }); - - if (!err) { - this.props.onSubmit( - values, - msg => { - const { setFieldsValue, getFieldsValue } = this.props.form; - this.setState({ isSubmitting: false }); - setFieldsValue(getFieldsValue()); // reset form touched state - notification.success(msg); - }, - msg => { - this.setState({ isSubmitting: false }); - notification.error(msg); - } - ); - } else this.setState({ isSubmitting: false }); - }); - }; - - handleAction = e => { - const actionName = e.target.dataset.action; - - this.setActionInProgress(actionName, true); - this.actionCallbacks[actionName](() => { - this.setActionInProgress(actionName, false); - }); - }; - - base64File = (fieldName, e) => { - if (e && e.fileList[0]) { - helper.getBase64(e.file).then(value => { - this.props.form.setFieldsValue({ [fieldName]: value }); - }); +function normalizeEmptyValuesToNull(fields, values) { + return mapValues(values, (value, key) => { + const { initialValue } = find(fields, { name: key }) || {}; + if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") { + return null; } - }; + return value; + }); +} - renderUpload(field, props) { - const { getFieldDecorator, getFieldValue } = this.props.form; - const { name, initialValue } = field; +function DynamicFormFields({ fields, feedbackIcons, form }) { + return fields.map(field => { + const { name, type, initialValue, contentAfter } = field; + const fieldLabel = getFieldLabel(field); - const fileOptions = { + const formItemProps = { + name, + className: "m-b-10", + hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons, + label: type === "checkbox" ? "" : fieldLabel, rules: fieldRules(field), + valuePropName: type === "checkbox" ? "checked" : "value", initialValue, - getValueFromEvent: this.base64File.bind(this, name), }; - const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue; + if (type === "file") { + formItemProps.valuePropName = "data-value"; + formItemProps.getValueFromEvent = e => { + if (e && e.fileList[0]) { + helper.getBase64(e.file).then(value => { + form.setFieldsValue({ [name]: value }); + }); + } + return undefined; + }; + } - const upload = ( - false}> - - + return ( + + + + + {isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter} + ); + }); +} - return getFieldDecorator(name, fileOptions)(upload); - } - - renderSelect(field, props) { - const { getFieldDecorator } = this.props.form; - const { name, options, mode, initialValue, readOnly, loading } = field; - const { Option } = Select; +DynamicFormFields.propTypes = { + fields: PropTypes.arrayOf(FieldType), + feedbackIcons: PropTypes.bool, + form: AntdFormType.isRequired, +}; - const decoratorOptions = { - rules: fieldRules(field), - initialValue, - }; +DynamicFormFields.defaultProps = { + fields: [], + feedbackIcons: false, +}; - return getFieldDecorator( - name, - decoratorOptions - )( - - ); +const reducerForActionSet = (state, action) => { + if (action.inProgress) { + state.add(action.actionName); + } else { + state.delete(action.actionName); } + return new Set(state); +}; - renderField(field, props) { - const { getFieldDecorator } = this.props.form; - const { name, type, initialValue } = field; - const fieldLabel = field.title || toHuman(name); - - const options = { - rules: fieldRules(field), - valuePropName: type === "checkbox" ? "checked" : "value", - initialValue, - }; +function DynamicFormActions({ actions, isFormDirty }) { + const [inProgressActions, setActionInProgress] = useReducer(reducerForActionSet, new Set()); - if (type === "checkbox") { - return getFieldDecorator(name, options)({fieldLabel}); - } else if (type === "file") { - return this.renderUpload(field, props); - } else if (type === "select") { - return this.renderSelect(field, props); - } else if (type === "content") { - return field.content; - } else if (type === "number") { - return getFieldDecorator(name, options)(); - } else if (type === "textarea") { - return getFieldDecorator(name, options)(); - } else if (type === "ace") { - return getFieldDecorator(name, options)(); + const handleAction = useCallback(action => { + const actionName = action.name; + if (isFunction(action.callback)) { + setActionInProgress({ actionName, inProgress: true }); + action.callback(() => { + setActionInProgress({ actionName, inProgress: false }); + }); } - return getFieldDecorator(name, options)(); - } - - renderFields(fields) { - return fields.map(field => { - const FormItem = Form.Item; - const { name, title, type, readOnly, autoFocus, contentAfter } = field; - const fieldLabel = title || toHuman(name); - const { feedbackIcons, form } = this.props; + }, []); + return actions.map(action => ( + + )); +} - const formItemProps = { - className: "m-b-10", - hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons, - label: type === "checkbox" ? "" : fieldLabel, - }; +DynamicFormActions.propTypes = { + actions: PropTypes.arrayOf(ActionType), + isFormDirty: PropTypes.bool, +}; - const fieldProps = { - ...field.props, - className: "w-100", - name, - type, - readOnly, - autoFocus, - placeholder: field.placeholder, - "data-test": fieldLabel, - }; +DynamicFormActions.defaultProps = { + actions: [], + isFormDirty: false, +}; - return ( - - {this.renderField(field, fieldProps)} - {isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter} - +export default function DynamicForm({ + id, + fields, + actions, + feedbackIcons, + hideSubmitButton, + defaultShowExtraFields, + saveText, + onSubmit, +}) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [showExtraFields, setShowExtraFields] = useState(defaultShowExtraFields); + const [form] = Form.useForm(); + const extraFields = filter(fields, { extra: true }); + const regularFields = difference(fields, extraFields); + + const handleFinish = useCallback( + values => { + setIsSubmitting(true); + values = normalizeEmptyValuesToNull(fields, values); + onSubmit( + values, + msg => { + const { setFieldsValue, getFieldsValue } = form; + setIsSubmitting(false); + setFieldsValue(getFieldsValue()); // reset form touched state + notification.success(msg); + }, + msg => { + setIsSubmitting(false); + notification.error(msg); + } ); - }); - } - - renderActions() { - return this.props.actions.map(action => { - const inProgress = this.state.inProgressActions[action.name]; - const { isFieldsTouched } = this.props.form; - - const actionProps = { - key: action.name, - htmlType: "button", - className: action.pullRight ? "pull-right m-t-10" : "m-t-10", - type: action.type, - disabled: isFieldsTouched() && action.disableWhenDirty, - loading: inProgress, - onClick: this.handleAction, - }; - - return ( - + + + + + )} + {!hideSubmitButton && ( + - ); - }); - } - - render() { - const submitProps = { - type: "primary", - htmlType: "submit", - className: "w-100 m-t-20", - disabled: this.state.isSubmitting, - loading: this.state.isSubmitting, - }; - const { id, hideSubmitButton, saveText, fields } = this.props; - const { showExtraFields } = this.state; - const saveButton = !hideSubmitButton; - const extraFields = filter(fields, { extra: true }); - const regularFields = difference(fields, extraFields); - - return ( - - {this.renderFields(regularFields)} - {!isEmpty(extraFields) && ( -
- - - {this.renderFields(extraFields)} - -
- )} - {saveButton && } - {this.renderActions()} - - ); - } + )} + + + ); } -export default Form.create()(DynamicForm); +DynamicForm.propTypes = { + id: PropTypes.string, + fields: PropTypes.arrayOf(FieldType), + actions: PropTypes.arrayOf(ActionType), + feedbackIcons: PropTypes.bool, + hideSubmitButton: PropTypes.bool, + defaultShowExtraFields: PropTypes.bool, + saveText: PropTypes.string, + onSubmit: PropTypes.func, +}; + +DynamicForm.defaultProps = { + id: null, + fields: [], + actions: [], + feedbackIcons: false, + hideSubmitButton: false, + defaultShowExtraFields: false, + saveText: "Save", + onSubmit: () => {}, +}; diff --git a/client/app/components/dynamic-form/DynamicFormField.jsx b/client/app/components/dynamic-form/DynamicFormField.jsx new file mode 100644 index 0000000000..75f1a76a5c --- /dev/null +++ b/client/app/components/dynamic-form/DynamicFormField.jsx @@ -0,0 +1,82 @@ +import React from "react"; +import { get } from "lodash"; +import PropTypes from "prop-types"; +import getFieldLabel from "./getFieldLabel"; + +import { + AceEditorField, + CheckboxField, + ContentField, + FileField, + InputField, + NumberField, + SelectField, + TextAreaField, +} from "./fields"; + +export const FieldType = PropTypes.shape({ + name: PropTypes.string.isRequired, + title: PropTypes.string, + type: PropTypes.oneOf([ + "ace", + "text", + "textarea", + "email", + "password", + "number", + "checkbox", + "file", + "select", + "content", + ]).isRequired, + initialValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + PropTypes.arrayOf(PropTypes.string), + PropTypes.arrayOf(PropTypes.number), + ]), + content: PropTypes.node, + mode: PropTypes.string, + required: PropTypes.bool, + extra: PropTypes.bool, + readOnly: PropTypes.bool, + autoFocus: PropTypes.bool, + minLength: PropTypes.number, + placeholder: PropTypes.string, + contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + loading: PropTypes.bool, + props: PropTypes.object, // eslint-disable-line react/forbid-prop-types +}); + +const FieldTypeComponent = { + checkbox: CheckboxField, + file: FileField, + select: SelectField, + number: NumberField, + textarea: TextAreaField, + ace: AceEditorField, + content: ContentField, +}; + +export default function DynamicFormField({ form, field, ...otherProps }) { + const { name, type, readOnly, autoFocus } = field; + const fieldLabel = getFieldLabel(field); + + const fieldProps = { + ...field.props, + className: "w-100", + name, + type, + readOnly, + autoFocus, + placeholder: field.placeholder, + "data-test": fieldLabel, + ...otherProps, + }; + + const FieldComponent = get(FieldTypeComponent, type, InputField); + return ; +} + +DynamicFormField.propTypes = { field: FieldType.isRequired }; diff --git a/client/app/components/dynamic-form/dynamicFormHelper.js b/client/app/components/dynamic-form/dynamicFormHelper.js index 1401fa8415..9200aa43fc 100644 --- a/client/app/components/dynamic-form/dynamicFormHelper.js +++ b/client/app/components/dynamic-form/dynamicFormHelper.js @@ -100,6 +100,13 @@ function getFields(type = {}, target = { options: {} }) { placeholder: `My ${type.name}`, autoFocus: isNewTarget, }, + { + name: "description", + title: "Description", + type: "text", + required: false, + initialValue: target.description, + }, ...orderedInputs(configurationSchema.properties, configurationSchema.order, target.options), ]; @@ -108,6 +115,7 @@ function getFields(type = {}, target = { options: {} }) { function updateTargetWithValues(target, values) { target.name = values.name; + target.description = values.description; Object.keys(values).forEach(key => { if (key !== "name") { target.options[key] = values[key]; diff --git a/client/app/components/dynamic-form/fields/AceEditorField.jsx b/client/app/components/dynamic-form/fields/AceEditorField.jsx new file mode 100644 index 0000000000..b634cd1550 --- /dev/null +++ b/client/app/components/dynamic-form/fields/AceEditorField.jsx @@ -0,0 +1,6 @@ +import React from "react"; +import AceEditorInput from "@/components/AceEditorInput"; + +export default function AceEditorField({ form, field, ...otherProps }) { + return ; +} diff --git a/client/app/components/dynamic-form/fields/CheckboxField.jsx b/client/app/components/dynamic-form/fields/CheckboxField.jsx new file mode 100644 index 0000000000..e2c61b3fb4 --- /dev/null +++ b/client/app/components/dynamic-form/fields/CheckboxField.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import Checkbox from "antd/lib/checkbox"; +import getFieldLabel from "../getFieldLabel"; + +export default function CheckboxField({ form, field, ...otherProps }) { + const fieldLabel = getFieldLabel(field); + return {fieldLabel}; +} diff --git a/client/app/components/dynamic-form/fields/ContentField.jsx b/client/app/components/dynamic-form/fields/ContentField.jsx new file mode 100644 index 0000000000..40ae6302a5 --- /dev/null +++ b/client/app/components/dynamic-form/fields/ContentField.jsx @@ -0,0 +1,3 @@ +export default function ContentField({ field }) { + return field.content; +} diff --git a/client/app/components/dynamic-form/fields/FileField.jsx b/client/app/components/dynamic-form/fields/FileField.jsx new file mode 100644 index 0000000000..a1d57a8eb5 --- /dev/null +++ b/client/app/components/dynamic-form/fields/FileField.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import Button from "antd/lib/button"; +import Upload from "antd/lib/upload"; +import UploadOutlinedIcon from "@ant-design/icons/UploadOutlined"; + +export default function FileField({ form, field, ...otherProps }) { + const { name, initialValue } = field; + const { getFieldValue } = form; + const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue; + + return ( + false}> + + + ); +} diff --git a/client/app/components/dynamic-form/fields/InputField.jsx b/client/app/components/dynamic-form/fields/InputField.jsx new file mode 100644 index 0000000000..44c45b83ad --- /dev/null +++ b/client/app/components/dynamic-form/fields/InputField.jsx @@ -0,0 +1,6 @@ +import React from "react"; +import Input from "antd/lib/input"; + +export default function InputField({ form, field, ...otherProps }) { + return ; +} diff --git a/client/app/components/dynamic-form/fields/NumberField.jsx b/client/app/components/dynamic-form/fields/NumberField.jsx new file mode 100644 index 0000000000..5184b983ef --- /dev/null +++ b/client/app/components/dynamic-form/fields/NumberField.jsx @@ -0,0 +1,6 @@ +import React from "react"; +import InputNumber from "antd/lib/input-number"; + +export default function NumberField({ form, field, ...otherProps }) { + return ; +} diff --git a/client/app/components/dynamic-form/fields/SelectField.jsx b/client/app/components/dynamic-form/fields/SelectField.jsx new file mode 100644 index 0000000000..0f1d416bae --- /dev/null +++ b/client/app/components/dynamic-form/fields/SelectField.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import Select from "antd/lib/select"; + +export default function SelectField({ form, field, ...otherProps }) { + const { readOnly } = field; + return ( + + ); +} diff --git a/client/app/components/dynamic-form/fields/TextAreaField.jsx b/client/app/components/dynamic-form/fields/TextAreaField.jsx new file mode 100644 index 0000000000..988bfbc839 --- /dev/null +++ b/client/app/components/dynamic-form/fields/TextAreaField.jsx @@ -0,0 +1,6 @@ +import React from "react"; +import Input from "antd/lib/input"; + +export default function TextAreaField({ form, field, ...otherProps }) { + return ; +} diff --git a/client/app/components/dynamic-form/fields/index.js b/client/app/components/dynamic-form/fields/index.js new file mode 100644 index 0000000000..6a87500dbb --- /dev/null +++ b/client/app/components/dynamic-form/fields/index.js @@ -0,0 +1,8 @@ +export { default as AceEditorField } from "./AceEditorField"; +export { default as CheckboxField } from "./CheckboxField"; +export { default as ContentField } from "./ContentField"; +export { default as FileField } from "./FileField"; +export { default as InputField } from "./InputField"; +export { default as NumberField } from "./NumberField"; +export { default as SelectField } from "./SelectField"; +export { default as TextAreaField } from "./TextAreaField"; diff --git a/client/app/components/dynamic-form/getFieldLabel.js b/client/app/components/dynamic-form/getFieldLabel.js new file mode 100644 index 0000000000..bdeabd5fce --- /dev/null +++ b/client/app/components/dynamic-form/getFieldLabel.js @@ -0,0 +1,6 @@ +import { toHuman } from "@/lib/utils"; + +export default function getFieldLabel(field) { + const { title, name } = field; + return title || toHuman(name); +} diff --git a/client/app/components/dynamic-parameters/DateParameter.jsx b/client/app/components/dynamic-parameters/DateParameter.jsx index 0403b5e08b..ae6e6dff40 100644 --- a/client/app/components/dynamic-parameters/DateParameter.jsx +++ b/client/app/components/dynamic-parameters/DateParameter.jsx @@ -93,20 +93,21 @@ class DateParameter extends React.Component { } return ( - - } - {...additionalAttributes} - /> +
+ + +
); } } diff --git a/client/app/components/dynamic-parameters/DateRangeParameter.jsx b/client/app/components/dynamic-parameters/DateRangeParameter.jsx index 053ef38b83..a6df0aa8a8 100644 --- a/client/app/components/dynamic-parameters/DateRangeParameter.jsx +++ b/client/app/components/dynamic-parameters/DateRangeParameter.jsx @@ -208,21 +208,22 @@ class DateRangeParameter extends React.Component { } return ( - - } - {...additionalAttributes} - /> +
+ + +
); } } diff --git a/client/app/components/dynamic-parameters/DynamicButton.jsx b/client/app/components/dynamic-parameters/DynamicButton.jsx index eac322854a..7b86ae39e7 100644 --- a/client/app/components/dynamic-parameters/DynamicButton.jsx +++ b/client/app/components/dynamic-parameters/DynamicButton.jsx @@ -2,12 +2,15 @@ import React, { useRef } from "react"; import PropTypes from "prop-types"; import { isFunction, get, findIndex } from "lodash"; import Dropdown from "antd/lib/dropdown"; -import Icon from "antd/lib/icon"; import Menu from "antd/lib/menu"; import Typography from "antd/lib/typography"; import { DynamicDateType } from "@/services/parameters/DateParameter"; import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter"; +import ArrowLeftOutlinedIcon from "@ant-design/icons/ArrowLeftOutlined"; +import ThunderboltTwoToneIcon from "@ant-design/icons/ThunderboltTwoTone"; +import ThunderboltOutlinedIcon from "@ant-design/icons/ThunderboltOutlined"; + import "./DynamicButton.less"; const { Text } = Typography; @@ -28,7 +31,7 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) { {enabled && } {enabled && ( - + Back to Static Value )} @@ -45,7 +48,13 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) { className="dynamic-button" placement="bottomRight" trigger={["click"]} - icon={} + icon={ + enabled ? ( + + ) : ( + + ) + } getPopupContainer={() => containerRef.current} data-test="DynamicButton" /> diff --git a/client/app/components/dynamic-parameters/DynamicParameters.less b/client/app/components/dynamic-parameters/DynamicParameters.less index 7a6a56d443..e8d7ce9041 100644 --- a/client/app/components/dynamic-parameters/DynamicParameters.less +++ b/client/app/components/dynamic-parameters/DynamicParameters.less @@ -1,8 +1,10 @@ -@import '../../assets/less/inc/variables'; +@import "../../assets/less/inc/variables"; .redash-datepicker { - .ant-calendar-picker-clear { - right: 35px; + padding-right: 35px !important; + + &.ant-picker-range .ant-picker-clear { + right: 35px !important; background: transparent; } @@ -14,17 +16,19 @@ & ::placeholder { color: @text-color !important; } - + &.date-range-input { - .ant-calendar-range-picker-input { - width: 100%; - text-align: left; + .ant-picker-active-bar { + opacity: 0; } - - .ant-calendar-range-picker-separator, - .ant-calendar-range-picker-input:not(:first-child) { + + .ant-picker-separator { display: none; } + + .ant-picker-input:not(:first-child) { + width: 0; + } } } } diff --git a/client/app/components/empty-state/EmptyState.d.ts b/client/app/components/empty-state/EmptyState.d.ts new file mode 100644 index 0000000000..1bbdfd489e --- /dev/null +++ b/client/app/components/empty-state/EmptyState.d.ts @@ -0,0 +1,41 @@ +import React from "react"; + +type DefaultStepKey = "dataSources" | "queries" | "alerts" | "dashboards" | "users"; +export type StepKey = DefaultStepKey | K; + +export interface StepItem { + key: StepKey; + node: React.ReactNode; +} + +export interface EmptyStateProps { + header?: string; + icon?: string; + description: string; + illustration: string; + illustrationPath?: string; + helpLink: string; + + onboardingMode?: boolean; + showAlertStep?: boolean; + showDashboardStep?: boolean; + showDataSourceStep?: boolean; + showInviteStep?: boolean; + + getStepsItems?: (items: Array>) => Array>; +} + +declare class EmptyState extends React.Component> {} + +export default EmptyState; + +export interface StepProps { + show: boolean; + completed: boolean; + url?: string; + urlText?: string; + text: string; + onClick?: () => void; +} + +export declare const Step: React.FunctionComponent; diff --git a/client/app/components/empty-state/EmptyState.jsx b/client/app/components/empty-state/EmptyState.jsx index 1a5ee84b95..9c4493508d 100644 --- a/client/app/components/empty-state/EmptyState.jsx +++ b/client/app/components/empty-state/EmptyState.jsx @@ -7,7 +7,7 @@ import { currentUser } from "@/services/auth"; import organizationStatus from "@/services/organizationStatus"; import "./empty-state.less"; -function Step({ show, completed, text, url, urlText, onClick }) { +export function Step({ show, completed, text, url, urlText, onClick }) { if (!show) { return null; } @@ -46,10 +46,13 @@ function EmptyState({ onboardingMode, showAlertStep, showDashboardStep, + showDataSourceStep, showInviteStep, + getStepsItems, + illustrationPath, }) { const isAvailable = { - dataSource: true, + dataSource: showDataSourceStep, query: true, alert: showAlertStep, dashboard: showDashboardStep, @@ -75,6 +78,92 @@ function EmptyState({ return null; } + const renderDataSourcesStep = () => { + if (currentUser.isAdmin) { + return ( + + ); + } + + return ( + + ); + }; + + const defaultStepsItems = [ + { + key: "dataSources", + node: renderDataSourcesStep(), + }, + { + key: "queries", + node: ( + + ), + }, + { + key: "alerts", + node: ( + + ), + }, + { + key: "dashboards", + node: ( + + ), + }, + { + key: "users", + node: ( + + ), + }, + ]; + + const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems; + const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg"; + return (
@@ -83,60 +172,11 @@ function EmptyState({

{description}

- {illustration + {illustration

Let's get started

-
    - {currentUser.isAdmin && ( - - )} - {!currentUser.isAdmin && ( - - )} - - - - -
+
    {stepsItems.map(item => item.node)}

Need more support?{" "} @@ -154,12 +194,16 @@ EmptyState.propTypes = { header: PropTypes.string, description: PropTypes.string.isRequired, illustration: PropTypes.string.isRequired, + illustrationPath: PropTypes.string, helpLink: PropTypes.string.isRequired, onboardingMode: PropTypes.bool, showAlertStep: PropTypes.bool, showDashboardStep: PropTypes.bool, + showDataSourceStep: PropTypes.bool, showInviteStep: PropTypes.bool, + + getStepItems: PropTypes.func, }; EmptyState.defaultProps = { @@ -169,6 +213,7 @@ EmptyState.defaultProps = { onboardingMode: false, showAlertStep: false, showDashboardStep: false, + showDataSourceStep: true, showInviteStep: false, }; diff --git a/client/app/components/groups/DetailsPageSidebar.jsx b/client/app/components/groups/DetailsPageSidebar.jsx index e9ef9ac3b6..6061b0b34b 100644 --- a/client/app/components/groups/DetailsPageSidebar.jsx +++ b/client/app/components/groups/DetailsPageSidebar.jsx @@ -24,12 +24,6 @@ export default function DetailsPageSidebar({ return ( - controller.updatePagination({ itemsPerPage })} - /> {canAddMembers && (