diff --git a/.circleci/Dockerfile.cypress b/.circleci/Dockerfile.cypress index 3efef14f49..bce08d28c5 100644 --- a/.circleci/Dockerfile.cypress +++ b/.circleci/Dockerfile.cypress @@ -3,7 +3,7 @@ FROM cypress/browsers:chrome67 ENV APP /usr/src/app WORKDIR $APP -RUN npm install --no-save cypress @percy/cypress > /dev/null +RUN npm install --no-save puppeteer@1.10.0 cypress@^3.1.5 @percy/cypress@^0.2.3 atob@2.1.2 > /dev/null COPY cypress $APP/cypress COPY cypress.json $APP/cypress.json diff --git a/.circleci/config.yml b/.circleci/config.yml index ac7e5bb0d1..b7f788dfbc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -58,7 +58,7 @@ jobs: environment: COMPOSE_FILE: .circleci/docker-compose.cypress.yml COMPOSE_PROJECT_NAME: cypress - PERCY_TOKEN_ENCODED: MWM3OGUzNzk4ZWQ2NTE4YTBhMDAwZDNiNWE1Nzc4ZjEzZjYyMzY1MjE0NjY0NDRiOGE5ODc5ZGYzYTU4ZmE4NQ== + PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA== docker: - image: circleci/node:8 steps: @@ -96,9 +96,7 @@ jobs: - setup_remote_docker - checkout - run: .circleci/update_version - - run: docker login -u $DOCKER_USER -p $DOCKER_PASS - - run: docker build -t redash/redash:$(.circleci/docker_tag) . - - run: docker push redash/redash:$(.circleci/docker_tag) + - run: .circleci/docker_build workflows: version: 2 build: @@ -109,21 +107,23 @@ workflows: - frontend-unit-tests - frontend-e2e-tests - build-tarball: - requires: - - backend-unit-tests - filters: - tags: - only: /v[0-9]+(\.[0-9\-a-z]+)*/ - branches: - only: - - master - - /release\/.*/ + requires: + - backend-unit-tests + - frontend-unit-tests + - frontend-e2e-tests + filters: + branches: + only: + - master + - /release\/.*/ - build-docker-image: - requires: - - backend-unit-tests - filters: - branches: - only: - - master - - preview-build - - /release\/.*/ + requires: + - backend-unit-tests + - frontend-unit-tests + - frontend-e2e-tests + filters: + branches: + only: + - master + - preview-image + - /release\/.*/ diff --git a/.circleci/docker-compose.cypress.yml b/.circleci/docker-compose.cypress.yml index 5305f41d4e..2483582ce7 100644 --- a/.circleci/docker-compose.cypress.yml +++ b/.circleci/docker-compose.cypress.yml @@ -23,7 +23,7 @@ services: REDASH_LOG_LEVEL: "INFO" REDASH_REDIS_URL: "redis://redis:6379/0" REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres" - QUEUES: "queries,scheduled_queries,celery" + QUEUES: "queries,scheduled_queries,celery,schemas" WORKERS_COUNT: 2 cypress: build: diff --git a/.circleci/docker_build b/.circleci/docker_build new file mode 100755 index 0000000000..50acc2f526 --- /dev/null +++ b/.circleci/docker_build @@ -0,0 +1,17 @@ +#!/bin/bash +VERSION=$(jq -r .version package.json) +VERSION_TAG=$VERSION.b$CIRCLE_BUILD_NUM + +docker login -u $DOCKER_USER -p $DOCKER_PASS + +if [ $CIRCLE_BRANCH = master ] || [ $CIRCLE_BRANCH = preview-image ] +then + docker build -t redash/redash:preview -t redash/preview:$VERSION_TAG . + docker push redash/redash:preview + docker push redash/preview:$VERSION_TAG +else + docker build -t redash/redash:$VERSION_TAG . + docker push redash/redash:$VERSION_TAG +fi + +echo "Built: $VERSION_TAG" \ No newline at end of file diff --git a/.circleci/docker_tag b/.circleci/docker_tag deleted file mode 100755 index 5f20a48bd0..0000000000 --- a/.circleci/docker_tag +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -if [ $CIRCLE_BRANCH = master ] || [ $CIRCLE_BRANCH = preview-build ] -then - FULL_VERSION='preview' -else - VERSION=$(jq -r .version package.json) - FULL_VERSION=$VERSION.b$CIRCLE_BUILD_NUM -fi - -echo $FULL_VERSION diff --git a/.codeclimate.yml b/.codeclimate.yml index c17ce2e20a..8034b775f6 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,22 +1,38 @@ -engines: +version: "2" +checks: + complex-logic: + enabled: false + file-lines: + enabled: false + method-complexity: + enabled: false + method-count: + enabled: false + method-lines: + config: + threshold: 100 + nested-control-flow: + enabled: false + identical-code: + enabled: false +plugins: pep8: enabled: true eslint: enabled: true - channel: "eslint-3" + channel: "eslint-5" config: config: client/.eslintrc.js checks: import/no-unresolved: enabled: false -ratings: - paths: - - "redash/**/*.py" - - "client/**/*.js" -exclude_paths: -- tests/**/*.py -- migrations/**/*.py -- old_migrations/**/*.py -- setup/**/* -- bin/**/* - + no-multiple-empty-lines: # TODO: Enable + enabled: false +exclude_patterns: +- "tests/**/*.py" +- "migrations/**/*.py" +- "setup/**/*" +- "bin/**/*" +- "**/node_modules/" +- "client/dist/" +- "**/*.pyc" diff --git a/.dockerignore b/.dockerignore index 69c145ad11..d233c7a9d5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,12 @@ client/dist/ node_modules/ .tmp/ .venv/ +venv/ .git/ +/.codeclimate.yml +/.coverage +/coverage.xml +/.circleci/ +/.github/ +/netlify.toml +/setup/ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..a4e1d25210 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## What type of PR is this? (check all applicable) + + +- [ ] Refactor +- [ ] Feature +- [ ] Bug Fix +- [ ] New Query Runner (Data Source) +- [ ] New Alert Destination +- [ ] Other + +## Description + +## Related Tickets & Documents + +## Mobile & Desktop Screenshots/Recordings (if there are UI changes) diff --git a/.github/support.yml b/.github/support.yml new file mode 100644 index 0000000000..164b588b36 --- /dev/null +++ b/.github/support.yml @@ -0,0 +1,23 @@ +# Configuration for Support Requests - https://github.com/dessant/support-requests + +# Label used to mark issues as support requests +supportLabel: Support Question + +# Comment to post on issues marked as support requests, `{issue-author}` is an +# optional placeholder. Set to `false` to disable +supportComment: > + :wave: @{issue-author}, we use the issue tracker exclusively for bug reports + and planned work. However, this issue appears to be a support request. + Please use [our forum](https://discuss.redash.io) to get help. + +# Close issues marked as support requests +close: true + +# Lock issues marked as support requests +lock: false + +# Assign `off-topic` as the reason for locking. Set to `false` to disable +setLockReason: true + +# Repository to extend settings from +# _extends: repo diff --git a/.github/weekly-digest.yml b/.github/weekly-digest.yml new file mode 100644 index 0000000000..08cced6393 --- /dev/null +++ b/.github/weekly-digest.yml @@ -0,0 +1,7 @@ +# Configuration for weekly-digest - https://github.com/apps/weekly-digest +publishDay: mon +canPublishIssues: true +canPublishPullRequests: true +canPublishContributors: true +canPublishStargazers: true +canPublishCommits: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1288782f5f..8bd64799b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,6 @@ The following is a set of guidelines for contributing to Redash. These are guide ## Quick Links: -- [Feature Roadmap](https://trello.com/b/b2LUHU7A/redash-roadmap) - [Feature Requests](https://discuss.redash.io/c/feature-requests) - [Documentation](https://redash.io/help/) - [Blog](https://blog.redash.io/) @@ -61,7 +60,7 @@ If you would like to suggest an enhancement or ask for a new feature: ### Documentation -The project's documentation can be found at [https://redash.io/help/](https://redash.io/help/). The [documentation sources](https://github.com/getredash/website/tree/master/website/_kb) are hosted on GitHub. To contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface. +The project's documentation can be found at [https://redash.io/help/](https://redash.io/help/). The [documentation sources](https://github.com/getredash/website/tree/master/src/pages/kb) are hosted on GitHub. To contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface. ## Additional Notes diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 8cc09a4949..c7d0e473b6 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -3,7 +3,7 @@ set -e worker() { WORKERS_COUNT=${WORKERS_COUNT:-2} - QUEUES=${QUEUES:-queries,scheduled_queries,celery} + QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas} echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..." exec /usr/local/bin/celery worker --app=redash.worker -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair @@ -27,6 +27,10 @@ create_db() { exec /app/manage.py database create_tables } +celery_healthcheck() { + exec /usr/local/bin/celery inspect ping --app=redash.worker -d celery@$HOSTNAME +} + help() { echo "Redash Docker." echo "" @@ -36,9 +40,11 @@ help() { echo "server -- start Redash server (with gunicorn)" echo "worker -- start Celery worker" echo "scheduler -- start Celery worker with a beat (scheduler) process" + echo "celery_healthcheck -- runs a Celery healthcheck. Useful for Docker's HEALTHCHECK mechanism." echo "" echo "shell -- open shell" echo "dev_server -- start Flask development server with debugger and auto reload" + echo "debug -- start Flask development server with remote debugger via ptvsd" echo "create_db -- create database tables" echo "manage -- CLI to manage redash" echo "tests -- run tests" @@ -72,6 +78,11 @@ case "$1" in export FLASK_DEBUG=1 exec /app/manage.py runserver --debugger --reload -h 0.0.0.0 ;; + debug) + export FLASK_DEBUG=1 + export REMOTE_DEBUG=1 + exec /app/manage.py runserver --debugger --no-reload -h 0.0.0.0 + ;; shell) exec /app/manage.py shell ;; diff --git a/client/.babelrc b/client/.babelrc index a6ae74fe1d..7ba3d2057b 100644 --- a/client/.babelrc +++ b/client/.babelrc @@ -1,8 +1,15 @@ { - "presets": ["env", "react", "stage-2"], + "presets": [ + ["@babel/preset-env", { + "targets": "> 0.5%, last 2 versions, Firefox ESR, ie 11, not dead", + "useBuiltIns": "usage" + }], + "@babel/preset-react" + ], "plugins": [ "angularjs-annotate", - "transform-object-assign", + "@babel/plugin-proposal-class-properties", + "@babel/plugin-transform-object-assign", ["babel-plugin-transform-builtin-extend", { "globals": ["Error"] }] diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 17552fcd15..73caba79e3 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -18,6 +18,7 @@ module.exports = { 'no-param-reassign': 0, 'no-mixed-operators': 0, 'no-underscore-dangle': 0, + "no-use-before-define": ["error", "nofunc"], "prefer-destructuring": "off", "prefer-template": "off", "no-restricted-properties": "off", @@ -26,15 +27,24 @@ module.exports = { "no-lonely-if": "off", "consistent-return": "off", "no-control-regex": "off", + 'no-multiple-empty-lines': 'warn', "no-script-url": "off", // some tags should have href="javascript:void(0)" + 'operator-linebreak': 'off', + 'react/destructuring-assignment': 'off', "react/jsx-filename-extension": "off", + 'react/jsx-one-expression-per-line': 'off', "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", + 'react/jsx-wrap-multilines': 'warn', + 'react/no-access-state-in-setstate': 'warn', "react/prefer-stateless-function": "warn", "react/forbid-prop-types": "warn", "react/prop-types": "warn", "jsx-a11y/anchor-is-valid": "off", "jsx-a11y/click-events-have-key-events": "off", + "jsx-a11y/label-has-associated-control": ["warn", { + "controlComponents": true + }], "jsx-a11y/label-has-for": "off", "jsx-a11y/no-static-element-interactions": "off", "max-len": ['error', 120, 2, { @@ -43,6 +53,8 @@ module.exports = { ignoreRegExpLiterals: true, ignoreStrings: true, ignoreTemplateLiterals: true, - }] + }], + "no-else-return": ["error", {"allowElseIf": true}], + "object-curly-newline": ["error", {"consistent": true}], } }; diff --git a/client/app/assets/images/db-logos/aws_es.png b/client/app/assets/images/db-logos/aws_es.png new file mode 100644 index 0000000000..cbf84873f3 Binary files /dev/null and b/client/app/assets/images/db-logos/aws_es.png differ diff --git a/client/app/assets/images/db-logos/uptycs.png b/client/app/assets/images/db-logos/uptycs.png new file mode 100644 index 0000000000..3f9ead8785 Binary files /dev/null and b/client/app/assets/images/db-logos/uptycs.png differ diff --git a/client/app/assets/images/destinations/hangouts_chat.png b/client/app/assets/images/destinations/hangouts_chat.png new file mode 100644 index 0000000000..ef934b0a2c Binary files /dev/null and b/client/app/assets/images/destinations/hangouts_chat.png differ diff --git a/client/app/assets/images/destinations/slack.png b/client/app/assets/images/destinations/slack.png index 857731a518..39c62a6e9d 100644 Binary files a/client/app/assets/images/destinations/slack.png and b/client/app/assets/images/destinations/slack.png differ diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index 9607696513..c42a83e2a7 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -1,33 +1,41 @@ -@import '~antd/lib/style/core/iconfont.less'; -@import '~antd/lib/style/core/motion.less'; -@import '~antd/lib/input/style/index.less'; -@import '~antd/lib/input-number/style/index.less'; -@import '~antd/lib/date-picker/style/index.less'; -@import '~antd/lib/modal/style/index.less'; -@import '~antd/lib/tooltip/style/index.less'; -@import '~antd/lib/select/style/index.less'; -@import '~antd/lib/checkbox/style/index.less'; -@import '~antd/lib/upload/style/index.less'; -@import '~antd/lib/form/style/index.less'; -@import '~antd/lib/button/style/index.less'; -@import '~antd/lib/radio/style/index.less'; -@import '~antd/lib/time-picker/style/index.less'; - -// Overwritting Ant Design defaults to fit into Redash current style -@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; - -@font-family-no-number : @redash-font; -@font-family : @redash-font; -@code-family : @redash-font; -@font-size-base : 13px; -@text-color : #767676; - -@input-height-base : 35px; -@input-color : #9E9E9E; -@border-radius-base : 2px; -@border-color-base : #e8e8e8; - -@primary-color : #2196F3; +@import '~antd/lib/style/core/iconfont'; +@import '~antd/lib/style/core/motion'; +@import '~antd/lib/alert/style/index'; +@import '~antd/lib/input/style/index'; +@import '~antd/lib/input-number/style/index'; +@import '~antd/lib/date-picker/style/index'; +@import '~antd/lib/modal/style/index'; +@import '~antd/lib/tooltip/style/index'; +@import '~antd/lib/select/style/index'; +@import '~antd/lib/checkbox/style/index'; +@import '~antd/lib/upload/style/index'; +@import '~antd/lib/form/style/index'; +@import '~antd/lib/button/style/index'; +@import '~antd/lib/radio/style/index'; +@import '~antd/lib/time-picker/style/index'; +@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'; +@import '~antd/lib/drawer/style/index'; +@import '~antd/lib/divider/style/index'; +@import '~antd/lib/dropdown/style/index'; +@import '~antd/lib/menu/style/index'; +@import '~antd/lib/list/style/index'; +@import "~antd/lib/badge/style/index"; +@import "~antd/lib/card/style/index"; +@import "~antd/lib/spin/style/index"; +@import "~antd/lib/tabs/style/index"; +@import 'inc/ant-variables'; + +// Remove bold in labels for Ant checkboxes and radio buttons +.ant-checkbox-wrapper, +.ant-radio-wrapper { + font-weight: normal; +} // Fix for disabled button styles inside Tooltip component. // Tooltip wraps disabled buttons with `` and moves all styles @@ -51,9 +59,191 @@ z-index: 1050; } +// Button overrides +.@{btn-prefix-cls} { + transition-duration: 150ms; +} + // Fix ant input number showing duplicate arrows .ant-input-number-input::-webkit-outer-spin-button, .ant-input-number-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } + +// Pagination overrides (based on existing Bootstrap overrides) +.@{pagination-prefix-cls} { + display: inline-block; + margin-top: 18px; + margin-bottom: 18px; + vertical-align: top; + + &-item { + background-color: @pagination-bg; + border-color: transparent; + color: @pagination-color; + font-size: 14px; + margin-right: 5px; + + a { + color: inherit; + } + + &:focus, + &:hover { + background-color: @pagination-hover-bg; + border-color: transparent; + color: @pagination-hover-color; + a { + color: inherit; + } + } + + &-active { + &, + &:hover, + &:focus { + background-color: @pagination-active-bg; + color: @pagination-active-color; + border-color: transparent; + pointer-events: none; + cursor: default; + + a { + color: inherit; + } + } + } + } + + &-disabled { + &, + &:hover, + &:focus { + opacity: 0.5; + pointer-events: none; + } + } + + &-prev, + &-next { + .@{pagination-prefix-cls}-item-link { + background-color: @pagination-bg; + border-color: transparent; + color: @pagination-color; + line-height: @pagination-item-size - 2px; + } + + &:focus .@{pagination-prefix-cls}-item-link, + &:hover .@{pagination-prefix-cls}-item-link { + background-color: @pagination-hover-bg; + border-color: transparent; + color: @pagination-hover-color; + } + } + + &-prev, + &-jump-prev, + &-jump-next { + margin-right: 5px; + } + + &-jump-prev, + &-jump-next { + .@{pagination-prefix-cls}-item-container { + .@{pagination-prefix-cls}-item-link-icon { + color: @pagination-color; + } + } + } +} + +// Table + +.@{table-prefix-cls} { + color: inherit; + + tr, + th, + td { + transition: none !important; + } + + &-thead > tr > th { + padding: @table-padding-vertical * 2 @table-padding-horizontal; + } + + .@{table-prefix-cls}-column-sorters { + &:before, + &:hover:before { + content: none; + } + } + + &-thead > tr > th { + .@{table-prefix-cls}-column-sorter { + &-up, + &-down { + &.on { + color: @table-header-icon-active-color; + } + } + } + } + + // Custom styles + + &-headerless &-tbody > tr:first-child > td { + border-top: @border-width-base @border-style-base @border-color-split; + } +} + +// List + +.@{list-prefix-cls} { + &-item { + // custom rule + &.selected { + background-color: #F6F8F9; + } + + &.disabled { + background-color: fade(#F6F8F9, 40%); + + & > * { + opacity: 0.4; + } + } + } +} + +// styling for short modals (no lines) +.@{dialog-prefix-cls}.shortModal { + .@{dialog-prefix-cls} { + &-header, + &-footer { + border: none; + padding: 16px; + } + &-body { + padding: 10px 16px; + } + &-close-x { + width: 46px; + height: 46px; + line-height: 46px; + } + } +} + +// description in modal header +.modal-header-desc { + font-size: @font-size-base; + color: @text-color-secondary; + font-weight: normal; + margin-top: 4px; +} + +.ant-popover { + z-index: 1000; // make sure it doesn't cover drawer +} diff --git a/client/app/assets/less/inc/ant-variables.less b/client/app/assets/less/inc/ant-variables.less new file mode 100644 index 0000000000..4e86e0b8b2 --- /dev/null +++ b/client/app/assets/less/inc/ant-variables.less @@ -0,0 +1,74 @@ +/* -------------------------------------------------------- + Colors +-----------------------------------------------------------*/ +@lightblue: #03A9F4; +@primary-color: #2196F3; + +@redash-gray: rgba(102, 136, 153, 1); +@redash-orange: rgba(255, 120, 100, 1); +@redash-black: rgba(0, 0, 0, 1); +@redash-yellow: rgba(252, 252, 161, 0.75); + +/* -------------------------------------------------------- + Font +-----------------------------------------------------------*/ +@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; +@font-family-no-number: @redash-font; +@font-family: @redash-font; +@code-family: @redash-font; +@font-size-base: 13px; + + +/* -------------------------------------------------------- + Typograpgy +-----------------------------------------------------------*/ +@text-color: #595959; + + +/* -------------------------------------------------------- + Form +-----------------------------------------------------------*/ +@input-height-base: 35px; +@input-color: #595959; +@border-radius-base: 2px; +@border-color-base: #E8E8E8; + + +/* -------------------------------------------------------- + Button +-----------------------------------------------------------*/ +@btn-danger-bg: fade(@redash-gray, 10%); +@btn-danger-border: fade(@redash-gray, 15%); + + +/* -------------------------------------------------------- + Pagination +-----------------------------------------------------------*/ +@pagination-item-size: 33px; +@pagination-font-family: @redash-font; +@pagination-font-weight-active: normal; + +@pagination-bg: fade(@redash-gray, 15%); +@pagination-color: #7E7E7E; +@pagination-active-bg: @lightblue; +@pagination-active-color: #FFF; +@pagination-disabled-bg: fade(@redash-gray, 15%); +@pagination-hover-color: #333; +@pagination-hover-bg: fade(@redash-gray, 25%); + + +/* -------------------------------------------------------- + Table +-----------------------------------------------------------*/ +@table-border-radius-base: 0; +@table-header-color: #333; +@table-header-bg: fade(@redash-gray, 3%); +@table-header-icon-color: fade(@text-color, 20%); +@table-header-icon-active-color: @text-color; +@table-header-sort-bg: @table-header-bg; +@table-header-sort-active-bg: @table-header-bg; +@table-header-filter-active-bg: @table-header-bg; +@table-body-sort-bg: transparent; +@table-row-hover-bg: fade(@redash-gray, 5%); +@table-padding-vertical: 7px; +@table-padding-horizontal: 10px; diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index c3642b6526..46865650a9 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -73,6 +73,10 @@ strong { } +.clickable { + cursor: pointer; +} + .resize-vertical { resize: vertical !important; transition: height 0s !important; diff --git a/client/app/assets/less/inc/generics.less b/client/app/assets/less/inc/generics.less index da230213de..0ad99bd54e 100755 --- a/client/app/assets/less/inc/generics.less +++ b/client/app/assets/less/inc/generics.less @@ -146,6 +146,8 @@ Width -----------------------------------------------------------*/ .w-100 { width: 100% !important; } +.w-50 { width: 50% !important; } +.w-25 { width: 25% !important; } /* -------------------------------------------------------- diff --git a/client/app/assets/less/inc/navbar.less b/client/app/assets/less/inc/navbar.less index 038daa1d0e..c65b309b66 100755 --- a/client/app/assets/less/inc/navbar.less +++ b/client/app/assets/less/inc/navbar.less @@ -1,6 +1,5 @@ a.navbar-brand { padding: 5px 5px 0px 0px; - margin-left: 0px !important; } .navbar .fa { diff --git a/client/app/assets/less/inc/variables.less b/client/app/assets/less/inc/variables.less index 702041d2a1..f93cf25226 100755 --- a/client/app/assets/less/inc/variables.less +++ b/client/app/assets/less/inc/variables.less @@ -55,7 +55,7 @@ /* -------------------------------------------------------- Form -----------------------------------------------------------*/ -@input-color: #9E9E9E; +@input-color: #595959; @input-color-placeholder: #b4b4b4; @input-border: #e8e8e8; @input-border-radius: 0; diff --git a/client/app/assets/less/inc/visualizations/misc.less b/client/app/assets/less/inc/visualizations/misc.less index a01778d59c..d439837db0 100644 --- a/client/app/assets/less/inc/visualizations/misc.less +++ b/client/app/assets/less/inc/visualizations/misc.less @@ -1,3 +1,6 @@ -visualization-renderer .pagination { - margin: 0; +visualization-renderer { + .pagination, + .ant-pagination { + margin: 0; + } } diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 20791a8c47..157c8b6823 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -202,8 +202,11 @@ edit-in-place p.editable:hover { } } -.visualization-renderer .pagination { - margin-top: 10px; +.visualization-renderer { + .pagination, + .ant-pagination { + margin-top: 10px; + } } .embed__vis { @@ -649,10 +652,6 @@ nav .rg-bottom { } } - a.navbar-brand { - display: none; - } - .datasource-small { visibility: visible; } @@ -663,10 +662,6 @@ nav .rg-bottom { display: none; } - a.navbar-brand { - display: block; - } - .filter-container { padding-right: 0; } diff --git a/client/app/assets/less/redash/redash-newstyle.less b/client/app/assets/less/redash/redash-newstyle.less index 711d546f38..97973d3bc0 100644 --- a/client/app/assets/less/redash/redash-newstyle.less +++ b/client/app/assets/less/redash/redash-newstyle.less @@ -58,27 +58,9 @@ body { border-left-color: #1b809e; } -.list-content { - @media (min-width: 992px) { - padding-right: 0; - } -} - -.list-control-r-b { - @media (max-width: 992px) { - display: none; - } -} - -.list-control-t { - @media (min-width: 992px) { - display: none; - } -} - // Fixed width layout for specific pages @media (min-width: 768px) { - settings-screen, home-page, page-dashboard-list, page-queries-list, alerts-list-page, alert-page, queries-search-results-page, .fixed-container { + settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { .container { width: 750px; } @@ -86,7 +68,7 @@ body { } @media (min-width: 992px) { - settings-screen, home-page, page-dashboard-list, page-queries-list, alerts-list-page, alert-page, queries-search-results-page, .fixed-container { + settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { .container { width: 970px; } @@ -94,7 +76,7 @@ body { } @media (min-width: 1200px) { - settings-screen, home-page, page-dashboard-list, page-queries-list, alerts-list-page, alert-page, queries-search-results-page, .fixed-container { + settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { .container { width: 1170px; } @@ -172,16 +154,12 @@ body { box-shadow: inset 3px 0px 0px @brand-primary; } -.table.table-data { - > tbody > tr > td { +.table-data { + tbody > tr > td { padding-top: 5px !important; } - tr:hover { - cursor: pointer; - } - - .btn-favourite { + .btn-favourite, .btn-archive { font-size: 15px; } } @@ -194,7 +172,7 @@ body { } } -.btn-favourite { +.btn-favourite, .btn-archive { color: #d4d4d4; transition: all .25s ease-in-out; @@ -207,7 +185,20 @@ body { } } -.page-header--new .btn-favourite { +.btn-archive { + color: #d4d4d4; + transition: all .25s ease-in-out; + + &:hover, &:focus { + color: @gray-light; + } + + .fa-archive { + color: @gray-light; + } +} + +.page-header--new .btn-favourite, .page-header--new .btn-archive { font-size: 19px; } @@ -243,7 +234,7 @@ body { } } -.navbar li a .btn-favourite .fa { +.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa { font-size: 100%; } @@ -370,7 +361,7 @@ body { padding: 20px; } -page-header, .page-header--new { +.page-header-wrapper, .page-header--new { h3 { margin: 0.2em 0; line-height: 1.3; @@ -472,7 +463,7 @@ page-header, .page-header--new { .label-tag-archived, .label-tag { margin-right: 3px; - display: inline-block; + display: inline; margin-top: 2px; max-width: 24ch; .text-overflow(); @@ -644,11 +635,6 @@ page-header, .page-header--new { background: #fff; margin-bottom: 10px; - a.navbar-brand { - padding: 4px 0px 0px 0px; - margin-left: -3px !important; - } - .btn-group.open .dropdown-toggle { -webkit-box-shadow: none; box-shadow: none; @@ -659,6 +645,18 @@ page-header, .page-header--new { } } +.navbar-link-ANGULAR_REMOVE_ME { + line-height: 18px; + padding: 10px 15px; + display: block; + + @media (min-width: 768px) { + padding-top: 16px; + padding-bottom: 16px; + } +} + +.navbar-link-ANGULAR_REMOVE_ME, .navbar-default .navbar-nav > li > a { color: #000; font-weight: 500; @@ -726,11 +724,10 @@ page-header, .page-header--new { .navbar-brand { position: absolute; - left: 49%; - margin-left: -50px !important; + left: 50%; + margin-left: -25px !important; // center display: block; zoom: 0.9; - margin-top: 3px; } .va-top { @@ -850,6 +847,7 @@ text.slicetext { .navbar-brand { left: 2%; + margin-left: 0 !important; } //Fix navbar collapse @@ -890,13 +888,28 @@ text.slicetext { } } -@media (min-width: 768px) and (max-width: 850px) { - .menu-search { - width: 175px; +@media (min-width: 768px) { + @media (max-width: 880px) { + .navbar-link-ANGULAR_REMOVE_ME, + .navbar-default .navbar-nav > li > a, + .navbar-form { + padding-left: 10px !important; + padding-right: 10px !important; + } + + a.navbar-brand { + margin-left: -15px !important; + } } - a.navbar-brand { - display: none !important; + @media (max-width: 810px) { + .menu-search { + width: 175px; + } + + a.navbar-brand { + margin-left: 13px !important; + } } } @@ -940,3 +953,26 @@ text.slicetext { } } +.ui-select-choices-row.disabled > span { + background-color: inherit !important; +} + +.list-group-item.inactive, +.ui-select-choices-row.disabled { + background-color: #eee !important; + border-color: transparent; + opacity: 0.5; + box-shadow: none; + color: #333; + pointer-events: none; + cursor: not-allowed; +} + +.select-option-divider { + margin: 10px 0 !important; +} + +.table-data .label-tag { + display: inline-block; + max-width: 135px; +} \ No newline at end of file diff --git a/client/app/assets/less/redash/tags-control.less b/client/app/assets/less/redash/tags-control.less index 3aaf402620..6698c4a5f7 100644 --- a/client/app/assets/less/redash/tags-control.less +++ b/client/app/assets/less/redash/tags-control.less @@ -8,6 +8,15 @@ &.inline-tags-control { display: inline-block; - vertical-align: middle; } + + &.disabled { + opacity: 0.4; + } +} + +// This is for using .inline-tags-control in Angular which renders +// a little differently than React (e.g. in Alert.html) +.inline-tags-control .tags-control { + display: inline-block; } diff --git a/client/app/components/AutocompleteToggle.jsx b/client/app/components/AutocompleteToggle.jsx index e74e0a9047..5a4c9bff20 100644 --- a/client/app/components/AutocompleteToggle.jsx +++ b/client/app/components/AutocompleteToggle.jsx @@ -2,7 +2,7 @@ import React from 'react'; import Tooltip from 'antd/lib/tooltip'; import PropTypes from 'prop-types'; import '@/redash-font/style.less'; -import recordEvent from '@/lib/recordEvent'; +import recordEvent from '@/services/recordEvent'; export default function AutocompleteToggle({ state, disabled, onToggle }) { let tooltipMessage = 'Live Autocomplete Enabled'; diff --git a/client/app/components/BigMessage.jsx b/client/app/components/BigMessage.jsx index e667113e6b..1063c1c950 100644 --- a/client/app/components/BigMessage.jsx +++ b/client/app/components/BigMessage.jsx @@ -2,9 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; -export function BigMessage({ message, icon, children }) { +export function BigMessage({ message, icon, children, className }) { return ( -
+

@@ -19,11 +19,13 @@ BigMessage.propTypes = { message: PropTypes.string, icon: PropTypes.string.isRequired, children: PropTypes.node, + className: PropTypes.string, }; BigMessage.defaultProps = { message: '', children: null, + className: 'tiled bg-white', }; export default function init(ngModule) { diff --git a/client/app/components/DateInput.jsx b/client/app/components/DateInput.jsx index 695a2a1997..733c3b669c 100644 --- a/client/app/components/DateInput.jsx +++ b/client/app/components/DateInput.jsx @@ -1,14 +1,13 @@ -import moment from 'moment'; import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; import DatePicker from 'antd/lib/date-picker'; +import { clientConfig } from '@/services/auth'; +import { Moment } from '@/components/proptypes'; export function DateInput({ value, onSelect, - // eslint-disable-next-line react/prop-types - clientConfig, className, }) { const format = clientConfig.dateFormat || 'YYYY-MM-DD'; @@ -28,13 +27,7 @@ export function DateInput({ } DateInput.propTypes = { - value: (props, propName, componentName) => { - const value = props[propName]; - if ((value !== null) && !moment.isMoment(value)) { - return new Error('Prop `' + propName + '` supplied to `' + componentName + - '` should be a Moment.js instance.'); - } - }, + value: Moment, onSelect: PropTypes.func, className: PropTypes.string, }; @@ -46,7 +39,7 @@ DateInput.defaultProps = { }; export default function init(ngModule) { - ngModule.component('dateInput', react2angular(DateInput, null, ['clientConfig'])); + ngModule.component('dateInput', react2angular(DateInput)); } init.init = true; diff --git a/client/app/components/DateRangeInput.jsx b/client/app/components/DateRangeInput.jsx index 1b1454bbb5..8dc2bd973f 100644 --- a/client/app/components/DateRangeInput.jsx +++ b/client/app/components/DateRangeInput.jsx @@ -1,17 +1,16 @@ -import moment from 'moment'; import { isArray } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; import DatePicker from 'antd/lib/date-picker'; +import { clientConfig } from '@/services/auth'; +import { Moment } from '@/components/proptypes'; const { RangePicker } = DatePicker; export function DateRangeInput({ value, onSelect, - // eslint-disable-next-line react/prop-types - clientConfig, className, }) { const format = clientConfig.dateFormat || 'YYYY-MM-DD'; @@ -30,18 +29,7 @@ export function DateRangeInput({ } DateRangeInput.propTypes = { - value: (props, propName, componentName) => { - const value = props[propName]; - if ( - (value !== null) && !( - isArray(value) && (value.length === 2) && - moment.isMoment(value[0]) && moment.isMoment(value[1]) - ) - ) { - return new Error('Prop `' + propName + '` supplied to `' + componentName + - '` should be an array of two Moment.js instances.'); - } - }, + value: PropTypes.arrayOf(Moment), onSelect: PropTypes.func, className: PropTypes.string, }; @@ -53,7 +41,7 @@ DateRangeInput.defaultProps = { }; export default function init(ngModule) { - ngModule.component('dateRangeInput', react2angular(DateRangeInput, null, ['clientConfig'])); + ngModule.component('dateRangeInput', react2angular(DateRangeInput)); } init.init = true; diff --git a/client/app/components/DateTimeInput.jsx b/client/app/components/DateTimeInput.jsx index d95fd60884..d51210813a 100644 --- a/client/app/components/DateTimeInput.jsx +++ b/client/app/components/DateTimeInput.jsx @@ -1,15 +1,14 @@ -import moment from 'moment'; import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; import DatePicker from 'antd/lib/date-picker'; +import { clientConfig } from '@/services/auth'; +import { Moment } from '@/components/proptypes'; export function DateTimeInput({ value, withSeconds, onSelect, - // eslint-disable-next-line react/prop-types - clientConfig, className, }) { const format = (clientConfig.dateFormat || 'YYYY-MM-DD') + @@ -31,13 +30,7 @@ export function DateTimeInput({ } DateTimeInput.propTypes = { - value: (props, propName, componentName) => { - const value = props[propName]; - if ((value !== null) && !moment.isMoment(value)) { - return new Error('Prop `' + propName + '` supplied to `' + componentName + - '` should be a Moment.js instance.'); - } - }, + value: Moment, withSeconds: PropTypes.bool, onSelect: PropTypes.func, className: PropTypes.string, @@ -51,7 +44,7 @@ DateTimeInput.defaultProps = { }; export default function init(ngModule) { - ngModule.component('dateTimeInput', react2angular(DateTimeInput, null, ['clientConfig'])); + ngModule.component('dateTimeInput', react2angular(DateTimeInput)); } init.init = true; diff --git a/client/app/components/DateTimeRangeInput.jsx b/client/app/components/DateTimeRangeInput.jsx index d0fa5ecb39..55e8ca7514 100644 --- a/client/app/components/DateTimeRangeInput.jsx +++ b/client/app/components/DateTimeRangeInput.jsx @@ -1,9 +1,10 @@ -import moment from 'moment'; import { isArray } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; import DatePicker from 'antd/lib/date-picker'; +import { clientConfig } from '@/services/auth'; +import { Moment } from '@/components/proptypes'; const { RangePicker } = DatePicker; @@ -11,8 +12,6 @@ export function DateTimeRangeInput({ value, withSeconds, onSelect, - // eslint-disable-next-line react/prop-types - clientConfig, className, }) { const format = (clientConfig.dateFormat || 'YYYY-MM-DD') + @@ -33,18 +32,7 @@ export function DateTimeRangeInput({ } DateTimeRangeInput.propTypes = { - value: (props, propName, componentName) => { - const value = props[propName]; - if ( - (value !== null) && !( - isArray(value) && (value.length === 2) && - moment.isMoment(value[0]) && moment.isMoment(value[1]) - ) - ) { - return new Error('Prop `' + propName + '` supplied to `' + componentName + - '` should be an array of two Moment.js instances.'); - } - }, + value: PropTypes.arrayOf(Moment), withSeconds: PropTypes.bool, onSelect: PropTypes.func, className: PropTypes.string, @@ -58,8 +46,7 @@ DateTimeRangeInput.defaultProps = { }; export default function init(ngModule) { - ngModule.component('dateTimeRangeInput', react2angular(DateTimeRangeInput, null, ['clientConfig'])); + ngModule.component('dateTimeRangeInput', react2angular(DateTimeRangeInput)); } init.init = true; - diff --git a/client/app/components/DialogWrapper.jsx b/client/app/components/DialogWrapper.jsx new file mode 100644 index 0000000000..de94a43c68 --- /dev/null +++ b/client/app/components/DialogWrapper.jsx @@ -0,0 +1,209 @@ +import { isFunction } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import ReactDOM from 'react-dom'; + +/** + Wrapper for dialogs based on Ant's component. + + + Using wrapped dialogs + ===================== + + Wrapped component is an object with two fields: + + { + showModal: (dialogProps) => object({ + result: Promise, + close: (result) => void, + dismiss: (reason) => void, + }), + Component: React.Component, // wrapped dialog component + } + + To open dialog, use `showModal` method; optionally you can pass additional properties that + will be expanded on wrapped component: + + const dialog = SomeWrappedDialog.showModal() + + const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' }) + + To get result of modal, use `result` property: + + dialog.result + .then(...) // pressed OK button or used `close` method; resolved value is a result of dialog + .catch(...) // pressed Cancel button or used `dismiss` method; optional argument is a rejection reason. + + Also, dialog has `close` and `dismiss` methods that allows to close dialog by caller. Passed arguments + will be used to resolve/reject `dialog.result` promise. `update` methods allows to pass new properties + to dialog. + + + Creating a dialog + ================ + + 1. Add imports: + + import { wrap as wrapDialog, DialogPropType } from 'path/to/DialogWrapper'; + + 2. define a `dialog` property on your component: + + propTypes = { + dialog: DialogPropType.isRequired, + }; + + `dialog` property is an object: + + { + props: object, // properties for component; + close: (result) => void, // method to confirm dialog; `result` will be returned to caller + dismiss: (reason) => void, // method to reject dialog; `reason` will be returned to caller + } + + 3. expand additional properties on component: + + render() { + const { dialog } = this.props; + return ( + + ); + } + + 4. wrap your component and export it: + + export default wrapDialog(YourComponent). + + Your component is ready to use. Wrapper will manage 's visibility and events. + If you want to override behavior of `onOk`/`onCancel` - don't forget to close dialog: + + customOkHandler() { + this.saveData().then(() => { + this.props.dialog.close({ success: true }); // or dismiss(); + }); + } + + render() { + const { dialog } = this.props; + return ( + this.customOkHandler()}> + ); + } + + + Settings + ======== + + You can setup this wrapper to use custom `Promise` library (for example, Bluebird): + + import DialogWrapper from 'path/to/DialogWrapper'; + import Promise from 'bluebird'; + + DialogWrapper.Promise = Promise; + + It could be useful to avoid `unhandledrejection` exception that would fire with native Promises, + or when some custom Promise library is used in application. + +*/ + +export const DialogPropType = PropTypes.shape({ + props: PropTypes.shape({ + visible: PropTypes.bool, + onOk: PropTypes.func, + onCancel: PropTypes.func, + afterClose: PropTypes.func, + }).isRequired, + close: PropTypes.func.isRequired, + dismiss: PropTypes.func.isRequired, +}); + +// default export of module +const DialogWrapper = { + Promise, + DialogPropType, + wrap() {}, +}; + +function openDialog(DialogComponent, props) { + const dialog = { + props: { + visible: true, + onOk: () => {}, + onCancel: () => {}, + afterClose: () => {}, + }, + close: () => {}, + dismiss: () => {}, + }; + + const dialogResult = { + resolve: () => {}, + reject: () => {}, + }; + + const container = document.createElement('div'); + document.body.appendChild(container); + + function render() { + ReactDOM.render(, container); + } + + function destroyDialog() { + // Allow calling chain to roll up, and then destroy component + setTimeout(() => { + ReactDOM.unmountComponentAtNode(container); + document.body.removeChild(container); + }, 10); + } + + function closeDialog(result) { + dialogResult.resolve(result); + dialog.props.visible = false; + render(); + } + + function dismissDialog(reason) { + dialogResult.reject(reason); + dialog.props.visible = false; + render(); + } + + dialog.props.onOk = closeDialog; + dialog.props.onCancel = dismissDialog; + dialog.props.afterClose = destroyDialog; + dialog.close = closeDialog; + dialog.dismiss = dismissDialog; + + const result = { + close: closeDialog, + dismiss: dismissDialog, + update: (newProps) => { + props = { ...props, ...newProps }; + render(); + }, + result: new DialogWrapper.Promise((resolve, reject) => { + dialogResult.resolve = resolve; + dialogResult.reject = reject; + }), + }; + + render(); // show it only when all structures initialized to avoid unnecessary re-rendering + + // Some known libraries support + // Bluebird: http://bluebirdjs.com/docs/api/suppressunhandledrejections.html + if (isFunction(result.result.suppressUnhandledRejections)) { + result.result.suppressUnhandledRejections(); + } + + return result; +} + +export function wrap(DialogComponent) { + return { + Component: DialogComponent, + showModal: props => openDialog(DialogComponent, props), + }; +} + +DialogWrapper.wrap = wrap; + +export default DialogWrapper; diff --git a/client/app/components/DynamicComponent.jsx b/client/app/components/DynamicComponent.jsx new file mode 100644 index 0000000000..645cb2b94b --- /dev/null +++ b/client/app/components/DynamicComponent.jsx @@ -0,0 +1,50 @@ +import { isFunction, isString } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; + +const componentsRegistry = new Map(); +const activeInstances = new Set(); + +export function registerComponent(name, component) { + if (isString(name) && name !== '') { + componentsRegistry.set(name, isFunction(component) ? component : null); + // Refresh active DynamicComponent instances which use this component + activeInstances.forEach((dynamicComponent) => { + if (dynamicComponent.props.name === name) { + dynamicComponent.forceUpdate(); + } + }); + } +} + +export function unregisterComponent(name) { + registerComponent(name, null); +} + +export default class DynamicComponent extends React.Component { + static propTypes = { + name: PropTypes.string.isRequired, + children: PropTypes.node, + }; + + static defaultProps = { + children: null, + }; + + componentDidMount() { + activeInstances.add(this); + } + + componentWillUnmount() { + activeInstances.delete(this); + } + + render() { + const { name, children, ...props } = this.props; + const RealComponent = componentsRegistry.get(name); + if (!RealComponent) { + return null; + } + return {children}; + } +} diff --git a/client/app/components/EditInPlace.jsx b/client/app/components/EditInPlace.jsx index 369f5d470b..2a923b33b3 100644 --- a/client/app/components/EditInPlace.jsx +++ b/client/app/components/EditInPlace.jsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; +import { trim } from 'lodash'; export class EditInPlace extends React.Component { static propTypes = { @@ -18,6 +19,7 @@ export class EditInPlace extends React.Component { placeholder: '', value: '', }; + constructor(props) { super(props); this.state = { @@ -39,7 +41,7 @@ export class EditInPlace extends React.Component { }; stopEditing = () => { - const newValue = this.inputRef.current.value; + const newValue = trim(this.inputRef.current.value); const ignorableBlank = this.props.ignoreBlanks && newValue === ''; if (!ignorableBlank && newValue !== this.props.value) { this.props.onDone(newValue); @@ -67,14 +69,13 @@ export class EditInPlace extends React.Component { ); - renderEdit = () => - React.createElement(this.props.editor, { - ref: this.inputRef, - className: 'rd-form-control', - defaultValue: this.props.value, - onBlur: this.stopEditing, - onKeyDown: this.keyDown, - }); + renderEdit = () => React.createElement(this.props.editor, { + ref: this.inputRef, + className: 'rd-form-control', + defaultValue: this.props.value, + onBlur: this.stopEditing, + onKeyDown: this.keyDown, + }); render() { return ( diff --git a/client/app/components/EditParameterSettingsDialog.jsx b/client/app/components/EditParameterSettingsDialog.jsx new file mode 100644 index 0000000000..01b0504156 --- /dev/null +++ b/client/app/components/EditParameterSettingsDialog.jsx @@ -0,0 +1,218 @@ + +import { includes, startsWith, words, capitalize, clone, isNull } from 'lodash'; +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import Modal from 'antd/lib/modal'; +import Form from 'antd/lib/form'; +import Checkbox from 'antd/lib/checkbox'; +import Button from 'antd/lib/button'; +import Select from 'antd/lib/select'; +import Input from 'antd/lib/input'; +import Divider from 'antd/lib/divider'; +import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; +import { QuerySelector } from '@/components/QuerySelector'; +import { Query } from '@/services/query'; + +const { Option } = Select; +const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } }; + +function getDefaultTitle(text) { + return capitalize(words(text).join(' ')); // humanize +} + +function isTypeDate(type) { + return startsWith(type, 'date') && !isTypeDateRange(type); +} + +function isTypeDateRange(type) { + return /-range/.test(type); +} + +function NameInput({ name, type, onChange, existingNames, setValidation }) { + let helpText = ''; + let validateStatus = ''; + + if (!name) { + helpText = 'Choose a keyword for this parameter'; + setValidation(false); + } else if (includes(existingNames, name)) { + helpText = 'Parameter with this name already exists'; + setValidation(false); + validateStatus = 'error'; + } else { + if (isTypeDateRange(type)) { + helpText = ( + + Appears in query as {' '} + + {`{{${name}.start}} {{${name}.end}}`} + + + ); + } + setValidation(true); + } + + return ( + + onChange(e.target.value)} autoFocus /> + + ); +} + +NameInput.propTypes = { + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + existingNames: PropTypes.arrayOf(PropTypes.string).isRequired, + setValidation: PropTypes.func.isRequired, + type: PropTypes.string.isRequired, +}; + +function EditParameterSettingsDialog(props) { + const [param, setParam] = useState(clone(props.parameter)); + const [isNameValid, setIsNameValid] = useState(true); + const [initialQuery, setInitialQuery] = useState(); + + const isNew = !props.parameter.name; + + // fetch query by id + useEffect(() => { + const { queryId } = props.parameter; + if (queryId) { + Query.get({ id: queryId }, (query) => { + setInitialQuery(query); + }); + } + }, []); + + function isFulfilled() { + // name + if (!isNameValid) { + return false; + } + + // title + if (param.title === '') { + return false; + } + + // query + if (param.type === 'query' && !param.queryId) { + return false; + } + + return true; + } + + function onConfirm(e) { + // update title to default + if (!param.title) { + // forced to do this cause param won't update in time for save + param.title = getDefaultTitle(param.name); + setParam(param); + } + + props.dialog.close(param); + + e.preventDefault(); // stops form redirect + } + + return ( + Cancel + ), ( + + )]} + > +
+ {isNew && ( + setParam({ ...param, name })} + setValidation={setIsNameValid} + existingNames={props.existingParams} + type={param.type} + /> + )} + + setParam({ ...param, title: e.target.value })} + /> + + + + + {isTypeDate(param.type) && ( + + setParam({ ...param, useCurrentDateTime: e.target.checked })} + > + Default to Today/Now if no other value is set + + + )} + {param.type === 'enum' && ( + + setParam({ ...param, enumOptions: e.target.value })} + /> + + )} + {param.type === 'query' && ( + + setParam({ ...param, queryId: q && q.id })} + type="select" + /> + + )} + +
+ ); +} + +EditParameterSettingsDialog.propTypes = { + parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + dialog: DialogPropType.isRequired, + existingParams: PropTypes.arrayOf(PropTypes.string), +}; + +EditParameterSettingsDialog.defaultProps = { + existingParams: [], +}; + +export default wrapDialog(EditParameterSettingsDialog); diff --git a/client/app/components/EmailSettingsWarning.jsx b/client/app/components/EmailSettingsWarning.jsx new file mode 100644 index 0000000000..7533c30881 --- /dev/null +++ b/client/app/components/EmailSettingsWarning.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; +import { currentUser, clientConfig } from '@/services/auth'; + +export function EmailSettingsWarning({ featureName }) { + return (clientConfig.mailSettingsMissing && currentUser.isAdmin) ? ( +

+ {`It looks like your mail server isn't configured. Make sure to configure it for the ${featureName} to work.`} +

+ ) : null; +} + +EmailSettingsWarning.propTypes = { + featureName: PropTypes.string.isRequired, +}; + +export default function init(ngModule) { + ngModule.component('emailSettingsWarning', react2angular(EmailSettingsWarning)); +} + +init.init = true; diff --git a/client/app/components/FavoritesControl.jsx b/client/app/components/FavoritesControl.jsx new file mode 100644 index 0000000000..665d7e7b83 --- /dev/null +++ b/client/app/components/FavoritesControl.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; +import { $rootScope } from '@/services/ng'; + +export class FavoritesControl extends React.Component { + static propTypes = { + item: PropTypes.shape({ + is_favorite: PropTypes.bool.isRequired, + }).isRequired, + onChange: PropTypes.func, + // Force component update when `item` changes. + // Remove this when `react2angular` will finally go to hell + forceUpdate: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + }; + + static defaultProps = { + onChange: () => {}, + forceUpdate: '', + }; + + toggleItem(event, item, callback) { + const action = item.is_favorite ? item.$unfavorite.bind(item) : item.$favorite.bind(item); + const savedIsFavorite = item.is_favorite; + + action().then(() => { + item.is_favorite = !savedIsFavorite; + this.forceUpdate(); + $rootScope.$broadcast('reloadFavorites'); + callback(); + }); + } + + render() { + const { item, onChange } = this.props; + const icon = item.is_favorite ? 'fa fa-star' : 'fa fa-star-o'; + const title = item.is_favorite ? 'Remove from favorites' : 'Add to favorites'; + return ( +
this.toggleItem(event, item, onChange)} + > +