mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
314 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16f551e22 | ||
|
|
e94515d340 | ||
|
|
8de1fa3318 | ||
|
|
6227a1d071 | ||
|
|
13b6bfc55f | ||
|
|
f5802d2dec | ||
|
|
ba0ccebe58 | ||
|
|
c5a65b3321 | ||
|
|
c622a76f3a | ||
|
|
76e0fa6e9c | ||
|
|
f0ba045913 | ||
|
|
7bf4219e58 | ||
|
|
fe477aa855 | ||
|
|
da09de6def | ||
|
|
f252821400 | ||
|
|
2cdc88293d | ||
|
|
d2d78e7676 | ||
|
|
c74ece4dda | ||
|
|
4a74263522 | ||
|
|
4edfd23772 | ||
|
|
c9b3c95464 | ||
|
|
959822cca6 | ||
|
|
4dea1d681f | ||
|
|
49b3dcaff7 | ||
|
|
b59e210d90 | ||
|
|
10b57b6ee2 | ||
|
|
cc21a32369 | ||
|
|
966b59906f | ||
|
|
a8440d32ab | ||
|
|
e7765440fc | ||
|
|
8af099b658 | ||
|
|
7e9db06633 | ||
|
|
194d4e1750 | ||
|
|
0207ba11a3 | ||
|
|
61a80ad8cc | ||
|
|
cbfd994a28 | ||
|
|
21ac9e8a97 | ||
|
|
d53d05cfb9 | ||
|
|
ee85923b14 | ||
|
|
4866be60de | ||
|
|
56d444b1a5 | ||
|
|
6b39437cdb | ||
|
|
b426e4fdc4 | ||
|
|
e5e926bac5 | ||
|
|
24d68008fa | ||
|
|
0e90b89acc | ||
|
|
2c2f241671 | ||
|
|
d49514abe9 | ||
|
|
934a145ced | ||
|
|
f7c70c2b91 | ||
|
|
69ba165565 | ||
|
|
7b5696dc75 | ||
|
|
4698408a08 | ||
|
|
be142d60df | ||
|
|
aceea6516f | ||
|
|
685b53672e | ||
|
|
7dd62ef948 | ||
|
|
7c2acc34c9 | ||
|
|
c5a90876f3 | ||
|
|
8abaf89394 | ||
|
|
aa2bd0042e | ||
|
|
a7b14bfb9a | ||
|
|
4e5f55a4b7 | ||
|
|
76fbe858ba | ||
|
|
cf7aef1e16 | ||
|
|
77625b2a13 | ||
|
|
c4dcf01b3c | ||
|
|
a167c590b6 | ||
|
|
8e23f93433 | ||
|
|
e41d40bbe0 | ||
|
|
6fc4d5b551 | ||
|
|
f0576a3623 | ||
|
|
9eabf89771 | ||
|
|
11cc274c1c | ||
|
|
8ad08a566a | ||
|
|
ef31d0d768 | ||
|
|
4640c33387 | ||
|
|
9b290913a6 | ||
|
|
db89c4f7bc | ||
|
|
eae1fb7d73 | ||
|
|
4f742aeaac | ||
|
|
5ddad862be | ||
|
|
6f811f163a | ||
|
|
7fb33e3ebb | ||
|
|
f165168860 | ||
|
|
86b0608fde | ||
|
|
cd4daf8823 | ||
|
|
78cae474e0 | ||
|
|
c518c7a4bc | ||
|
|
8c2f51d09d | ||
|
|
6f6c68bd79 | ||
|
|
64f274f58e | ||
|
|
dd89bd885f | ||
|
|
b2295197cf | ||
|
|
ea0e411053 | ||
|
|
9bdb3412a5 | ||
|
|
ad4a760545 | ||
|
|
c1f4147807 | ||
|
|
c054ae8be0 | ||
|
|
d1edd3d068 | ||
|
|
4989bfae60 | ||
|
|
f20a020003 | ||
|
|
01da8c158a | ||
|
|
c83e40b047 | ||
|
|
c3cc65a21d | ||
|
|
5929139ab8 | ||
|
|
66794acd1f | ||
|
|
bce0832e48 | ||
|
|
9f006997a0 | ||
|
|
51d8131db5 | ||
|
|
c793b5dd11 | ||
|
|
4e9da3f116 | ||
|
|
15a8eecdde | ||
|
|
a8ff2500be | ||
|
|
7bf84e856c | ||
|
|
5149bf67ca | ||
|
|
93449db325 | ||
|
|
df57d22e81 | ||
|
|
de0a44ee85 | ||
|
|
261062d491 | ||
|
|
1878e8bf90 | ||
|
|
47fc8a942a | ||
|
|
addecbdd8f | ||
|
|
baec5d56f5 | ||
|
|
1f4325ba8d | ||
|
|
5e5b56ed6a | ||
|
|
45a3b72730 | ||
|
|
cc48de0d8f | ||
|
|
300f3f6780 | ||
|
|
2e4a69cba4 | ||
|
|
6748e9a15d | ||
|
|
7ceb68a468 | ||
|
|
3c1d1e3d4e | ||
|
|
92391e3cbc | ||
|
|
17438002d7 | ||
|
|
a00c5a8857 | ||
|
|
a696fa55f3 | ||
|
|
27259b5abe | ||
|
|
9ee393ec75 | ||
|
|
cfafa97218 | ||
|
|
be580b24a5 | ||
|
|
a6960c5f19 | ||
|
|
6dd321beeb | ||
|
|
27c64b42ac | ||
|
|
99bf6d122c | ||
|
|
d617f57f7d | ||
|
|
21a27ee0b1 | ||
|
|
ac293c7f92 | ||
|
|
8e38dcd244 | ||
|
|
2bab144107 | ||
|
|
4e0a251034 | ||
|
|
7a9f4b07e0 | ||
|
|
1630cbb904 | ||
|
|
f8d05dda9f | ||
|
|
2af8b39d21 | ||
|
|
3faed0fdfe | ||
|
|
e45f49b86e | ||
|
|
e33ad3b164 | ||
|
|
6605f62f3a | ||
|
|
ed2ac407ab | ||
|
|
dda75cce24 | ||
|
|
5b780ac460 | ||
|
|
c0e8ef3000 | ||
|
|
a82fd0cabc | ||
|
|
0e3e2eaf38 | ||
|
|
05f6ef0fb6 | ||
|
|
e433efebc4 | ||
|
|
a9588eac79 | ||
|
|
090b570a71 | ||
|
|
60b12e3121 | ||
|
|
3f8c7333be | ||
|
|
be8dec5f04 | ||
|
|
10b3b50f3d | ||
|
|
6f290ddfa1 | ||
|
|
10b62ebe02 | ||
|
|
04453409da | ||
|
|
b27df216f4 | ||
|
|
a0c76d777b | ||
|
|
2e96e2fb98 | ||
|
|
c2e31f040d | ||
|
|
816f4d912f | ||
|
|
9292ae8d3f | ||
|
|
9480d89e4c | ||
|
|
5dff5b929c | ||
|
|
28e9740e2f | ||
|
|
7679df63ba | ||
|
|
07c9530984 | ||
|
|
aecd0bf37a | ||
|
|
4143bd3f20 | ||
|
|
020dc35faf | ||
|
|
d7b03bac02 | ||
|
|
29875e66d4 | ||
|
|
d97ce15837 | ||
|
|
b263bb7077 | ||
|
|
606cf12e74 | ||
|
|
4508975749 | ||
|
|
c76955be28 | ||
|
|
4f402379e8 | ||
|
|
733b60102d | ||
|
|
b9b30a39d2 | ||
|
|
c74d469181 | ||
|
|
95f11e6686 | ||
|
|
ad6f7109de | ||
|
|
b09ae46a9f | ||
|
|
0cda0369f0 | ||
|
|
50f11069ce | ||
|
|
6bf764be07 | ||
|
|
3159410694 | ||
|
|
76bd2e3c50 | ||
|
|
50a6f723b1 | ||
|
|
0ee20797c8 | ||
|
|
d7515562a4 | ||
|
|
feafbbe318 | ||
|
|
b7b345dacd | ||
|
|
0b22aa55a1 | ||
|
|
3eddea6e88 | ||
|
|
c85e097f8a | ||
|
|
81bc4ef58b | ||
|
|
9fec3ca9ea | ||
|
|
ee29cf9efc | ||
|
|
17aba39636 | ||
|
|
2cd1b07a41 | ||
|
|
72d00314a4 | ||
|
|
5b077ab083 | ||
|
|
da2d6bc3a8 | ||
|
|
33930a5b9c | ||
|
|
fbff4f9219 | ||
|
|
30f725f1e1 | ||
|
|
47cd05b48e | ||
|
|
9a4433bf68 | ||
|
|
d0b2151b4d | ||
|
|
21e22a2d0d | ||
|
|
f3a653c57f | ||
|
|
c9bf412240 | ||
|
|
48955b5fa1 | ||
|
|
24a5748528 | ||
|
|
8758279b14 | ||
|
|
99bb24d899 | ||
|
|
c93a905c1d | ||
|
|
a1e75d2f0b | ||
|
|
fb48bc374a | ||
|
|
10a6ccbbcd | ||
|
|
fea082ec77 | ||
|
|
aa9d2466cd | ||
|
|
97492d7aa0 | ||
|
|
18761cf07b | ||
|
|
9b3dd82ec0 | ||
|
|
e485c964c5 | ||
|
|
5b30d081d7 | ||
|
|
b96094b878 | ||
|
|
1f43537304 | ||
|
|
01e64db0dc | ||
|
|
3ab46bb39a | ||
|
|
af168c69b9 | ||
|
|
63e052c3a3 | ||
|
|
563e34a816 | ||
|
|
1524d06149 | ||
|
|
e9711a0b9c | ||
|
|
9fcf510ffd | ||
|
|
70227f2e43 | ||
|
|
1babd01f38 | ||
|
|
768bfb3525 | ||
|
|
fc5a624efb | ||
|
|
47bf91e150 | ||
|
|
8f4288583e | ||
|
|
595af3bce8 | ||
|
|
dba7efe030 | ||
|
|
1b142b33f1 | ||
|
|
13814c752d | ||
|
|
dd477d49ec | ||
|
|
5decd2624a | ||
|
|
6f9aee42a7 | ||
|
|
1333aae7fb | ||
|
|
33ad89a381 | ||
|
|
02a5852072 | ||
|
|
12782e4daf | ||
|
|
704b78a003 | ||
|
|
ec4f77c8b7 | ||
|
|
1871287a1f | ||
|
|
f9cc230227 | ||
|
|
fe4a7b65e7 | ||
|
|
b3819de878 | ||
|
|
2699d24441 | ||
|
|
1933dee8ca | ||
|
|
375e61f263 | ||
|
|
872d0ca5e6 | ||
|
|
973ad565cd | ||
|
|
7a7fdf9c99 | ||
|
|
49ffaae3ec | ||
|
|
d5494cff08 | ||
|
|
71afc99ec3 | ||
|
|
b5d97e25b7 | ||
|
|
6c26aa7a99 | ||
|
|
712fc63f93 | ||
|
|
77c53130a4 | ||
|
|
73c8e3096d | ||
|
|
8230098f50 | ||
|
|
fd42091f87 | ||
|
|
ec4b36b178 | ||
|
|
0995dfbf43 | ||
|
|
70d4c724c2 | ||
|
|
1d7378f84b | ||
|
|
b4a4ee212e | ||
|
|
25910e7655 | ||
|
|
8e5ba804f6 | ||
|
|
173f9ba7e8 | ||
|
|
e712c19bbe | ||
|
|
aea3c9dbaa | ||
|
|
2f8aade697 | ||
|
|
a7b930a422 | ||
|
|
4e69b73b0f | ||
|
|
c47dd05095 | ||
|
|
15c815fb5e | ||
|
|
9de676acee |
@@ -6,7 +6,7 @@ WORKDIR $APP
|
||||
COPY package.json $APP/package.json
|
||||
RUN npm run cypress:install > /dev/null
|
||||
|
||||
COPY cypress $APP/cypress
|
||||
COPY client/cypress $APP/client/cypress
|
||||
COPY cypress.json $APP/cypress.json
|
||||
|
||||
RUN ./node_modules/.bin/cypress verify
|
||||
|
||||
@@ -45,20 +45,34 @@ jobs:
|
||||
path: /tmp/test-results
|
||||
- store_artifacts:
|
||||
path: coverage.xml
|
||||
frontend-lint:
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- checkout
|
||||
- run: mkdir -p /tmp/test-results/eslint
|
||||
- run: npm install
|
||||
- run: npm run lint:ci
|
||||
- store_test_results:
|
||||
path: /tmp/test-results
|
||||
frontend-unit-tests:
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: sudo pip install -r requirements_bundles.txt
|
||||
- run: npm install
|
||||
- run: npm run bundle
|
||||
- run: 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
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
@@ -72,30 +86,20 @@ jobs:
|
||||
name: Setup Redash server
|
||||
command: |
|
||||
npm run cypress start
|
||||
docker-compose run cypress node ./cypress/cypress.js db-seed
|
||||
docker-compose run cypress npm run cypress db-seed
|
||||
- run:
|
||||
name: Execute Cypress tests
|
||||
command: npm run cypress run-ci
|
||||
build-tarball:
|
||||
build-docker-image:
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: npm install
|
||||
- run: .circleci/update_version
|
||||
- run: npm run bundle
|
||||
- run: npm run build
|
||||
- run: .circleci/pack
|
||||
- store_artifacts:
|
||||
path: /tmp/artifacts/
|
||||
build-docker-image:
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:xenial
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: sudo pip install -r requirements_bundles.txt
|
||||
- run: .circleci/update_version
|
||||
- run: npm run bundle
|
||||
- run: .circleci/docker_build
|
||||
workflows:
|
||||
version: 2
|
||||
@@ -104,19 +108,15 @@ workflows:
|
||||
- python-flake8-tests
|
||||
- legacy-python-flake8-tests
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
- build-tarball:
|
||||
- frontend-lint
|
||||
- frontend-unit-tests:
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- /release\/.*/
|
||||
- build-docker-image:
|
||||
- frontend-lint
|
||||
- frontend-e2e-tests:
|
||||
requires:
|
||||
- frontend-lint
|
||||
- hold:
|
||||
type: approval
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
@@ -127,3 +127,6 @@ workflows:
|
||||
- master
|
||||
- preview-image
|
||||
- /release\/.*/
|
||||
- build-docker-image:
|
||||
requires:
|
||||
- hold
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
REDASH_RATELIMIT_ENABLED: "false"
|
||||
worker:
|
||||
build: ../
|
||||
command: scheduler
|
||||
@@ -38,6 +39,12 @@ services:
|
||||
PERCY_BRANCH: ${CIRCLE_BRANCH}
|
||||
PERCY_COMMIT: ${CIRCLE_SHA1}
|
||||
PERCY_PULL_REQUEST: ${CIRCLE_PR_NUMBER}
|
||||
COMMIT_INFO_BRANCH: ${CIRCLE_BRANCH}
|
||||
COMMIT_INFO_AUTHOR: ${CIRCLE_USERNAME}
|
||||
COMMIT_INFO_SHA: ${CIRCLE_SHA1}
|
||||
COMMIT_INFO_REMOTE: ${CIRCLE_REPOSITORY_URL}
|
||||
CYPRESS_PROJECT_ID: ${CYPRESS_PROJECT_ID}
|
||||
CYPRESS_RECORD_KEY: ${CYPRESS_RECORD_KEY}
|
||||
redis:
|
||||
image: redis:3.0-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -6,4 +6,4 @@ FILENAME=$NAME.$FULL_VERSION.tar.gz
|
||||
|
||||
mkdir -p /tmp/artifacts/
|
||||
|
||||
tar -zcv -f /tmp/artifacts/$FILENAME --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" *
|
||||
tar -zcv -f /tmp/artifacts/$FILENAME --exclude=".git" --exclude="optipng*" --exclude="cypress" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" *
|
||||
|
||||
@@ -21,20 +21,12 @@ plugins:
|
||||
pep8:
|
||||
enabled: true
|
||||
eslint:
|
||||
enabled: true
|
||||
channel: "eslint-5"
|
||||
config:
|
||||
config: client/.eslintrc.js
|
||||
checks:
|
||||
import/no-unresolved:
|
||||
enabled: false
|
||||
no-multiple-empty-lines: # TODO: Enable
|
||||
enabled: false
|
||||
exclude_patterns:
|
||||
- "tests/**/*.py"
|
||||
- "migrations/**/*.py"
|
||||
- "setup/**/*"
|
||||
- "bin/**/*"
|
||||
- "**/node_modules/"
|
||||
- "client/dist/"
|
||||
- "**/*.pyc"
|
||||
- "tests/**/*.py"
|
||||
- "migrations/**/*.py"
|
||||
- "setup/**/*"
|
||||
- "bin/**/*"
|
||||
- "**/node_modules/"
|
||||
- "client/dist/"
|
||||
- "**/*.pyc"
|
||||
|
||||
@@ -9,6 +9,6 @@ trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{js,css,html}]
|
||||
[*.{js,jsx,css,less,html}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,5 +24,5 @@ node_modules
|
||||
.sass-cache
|
||||
npm-debug.log
|
||||
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
client/cypress/screenshots
|
||||
client/cypress/videos
|
||||
|
||||
113
CHANGELOG.md
113
CHANGELOG.md
@@ -1,5 +1,118 @@
|
||||
# Change Log
|
||||
|
||||
## v8.0.0-beta.2 - 2019-09-16
|
||||
|
||||
This is an update to the previous beta release, which includes:
|
||||
|
||||
* Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details).
|
||||
* Visualizations:
|
||||
- Allow the user to decide how to handle null values in charts.
|
||||
* Upgrade Sentry-SDK to latest version.
|
||||
* Make horizontal table scroll visible in dashboard widgets without scrolling.
|
||||
* Data Sources:
|
||||
* Add support for Azure Data Explorer (Kusto).
|
||||
* MySQL: fix connections without SSL configuration failing.
|
||||
* Amazon Redshift: option to set query group for adhoc/scheduled queries.
|
||||
* Hive: make error message more friendly.
|
||||
* Qubole: add support to run Quantum queries.
|
||||
* Display data source icon in query editor.
|
||||
* Fix: allow users with view only acces to use the queries in Query Results
|
||||
* Dashboard: when updating parameters refersh only widgets that use those parameters.
|
||||
|
||||
This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz.
|
||||
|
||||
|
||||
## v8.0.0-beta - 2019-08-18
|
||||
|
||||
After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements.
|
||||
|
||||
While this version is already running on the hosted platform to make sure it's stable, we're excited to put this in the hands of our Open Source users.
|
||||
|
||||
Starting from this release we will no longer build a tarball distribution of the codebase and recommend everyone to switch over to using our Docker images. We're planning on dropping Python 2 support towards its EOL this year and switching over to the Docker image will make this transition much simpler.
|
||||
|
||||
This release was made possible by contributions from over 40 people: @aidarbek, @AntonZarutsky, @ariarijp, @arikfr, @combineads, @deecay, @fmy, @gabrieldutra, @guwenqing, @guyco33, @ialeinikov, @Jakdaw, @jezdez, @justinclift, @k-tomoyasu, @katty0324, @koooge, @kravets-levko, @ktmud, @KumanoTanaka, @kyoshidajp, @nason, @oldPadavan, @openjck, @osule, @otsaloma, @ranbena, @rauchy, @rueian, @sekiyama58, @shinsuke-nara, @taminif, @The-Alchemist, @vv-p, @washort, @wudi-ayuan, @ygrishaev, @yoavbls, @yoshiken, @yusukegoto and the support of over 500 organizations who subscribed to our hosted version and by that sponsor the team's work.
|
||||
|
||||
### Parameters
|
||||
|
||||
- Parameter UI improvements:
|
||||
- Support for multi-select in dropdown (and query dropdown) parameters.
|
||||
- Support for dynamic values in date and date-range parameters.
|
||||
- Search dropdown parameter values.
|
||||
- New UX for applying parameter changes in queries and dashboards.
|
||||
- Allow using Safe Parameters in visualization embeds and public dashboards. Safe Parameters are any parameter type except for the a text parameter (dropdowns are safe).
|
||||
|
||||
### Data Sources
|
||||
|
||||
- New Data Sources: Couchbase, Phoenix and Dgraph.
|
||||
- New JSON data source (and deprecated old URL data source).
|
||||
- Snowflake: update connector to latest version.
|
||||
- PostgreSQL: show only accessible tables in schema.
|
||||
- BigQuery:
|
||||
- Correctly handle NaN values.
|
||||
- Treat repeated fields as rrays.
|
||||
- [BigQuery] Fix: in some queries there is no mode field
|
||||
- DynamoDB:
|
||||
- Support for Unicode in queries.
|
||||
- Safe loading of schema.
|
||||
- Rockset: better handling of query errors.
|
||||
- Google Sheets:
|
||||
- Support for Team Drive.
|
||||
- Friendlier error message in case of an API error and more reliable test connection.
|
||||
- MySQL:
|
||||
- Support for calling Stored Procedures and better handling of query cancellation.
|
||||
- Switch to using `mysqlclient` (a maintained fork of `Python-MySQL`).
|
||||
- MongoDB: Support serializing Decimal128 values.
|
||||
- Presto: support for passwords in connection settings.
|
||||
- Amazon Athena: allow to specify custom work group.
|
||||
- Query Results: querying a column with a dictionary or array fails
|
||||
- Clickhouse: make sure we don't show password in error messages.
|
||||
- Enable Cassandra support by default.
|
||||
|
||||
### Visualizations
|
||||
|
||||
- Charts:
|
||||
- Fix: legend overlapping chart on small screens.
|
||||
- Fix: Pie chart not rendering when series doesn't exist in options.
|
||||
- Pie Chart: add option to set direction of slices.
|
||||
- WordCloud: rewritten to support new options (provide frequency in query, limits), scale when resizing, handle long words and more.
|
||||
- Pivot Table: support hiding totals.
|
||||
- Counters: apply formatting to target value.
|
||||
- Maps:
|
||||
- Ability to customize marker icon and color.
|
||||
- Customization options for Choropleth maps.
|
||||
- New Visualization: Details View.
|
||||
|
||||
### **UX**
|
||||
|
||||
- Replace blank screen with a loading indicator when the application is doing its first load.
|
||||
- Multiple improvements to dashboards editing: auto-save, grid markings and better refresh indicator.
|
||||
- Admin can now edit user's groups from the user page.
|
||||
- Add keyboard shortcut (Ctrl/Cmd+Shift+F) to trigger query formatting.
|
||||
|
||||
### API
|
||||
|
||||
- Query Result API response minimized to only required fields when called with a non user API key.
|
||||
- Prefer API key over cookies in authentication.
|
||||
- User can now regenerate Query API Key.
|
||||
|
||||
### Other Changes
|
||||
|
||||
- Sends CSP headers to prevent various kinds of security attacks via the browser. Might break unusual usages and embeds of Redash.
|
||||
- New Failed Scheduled Queries email report (can be enabled from organization settings screen).
|
||||
- Deprecated HipChat Alert Destination.
|
||||
- Add options to hide different parts of a Visualization embed UI (parameters, title, link to query).
|
||||
- Support multi-byte search for query names and descriptions (needs to be enabled in Organization settings screen).
|
||||
- CSV query results download: correctly serialize booleans and date values.
|
||||
- Dashboard filters now collect values from all widgets with the same filter.
|
||||
- Support for custom message and description in alert notifications (currently disabled behind a feature flag until we improve the alert UX).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: adding widget to dashboard from a query page is broken.
|
||||
- Fix: default time format option was wrong.
|
||||
- Fix: when too many errors of a scheduled queries occur it causes an OverflowError.
|
||||
- Fix: when forking a query maintain the same visualizations order.
|
||||
|
||||
## v7.0.0 - 2019-03-17
|
||||
|
||||
We're trying a new format for the CHANGELOG in this release. Focusing on the bigger changes, but for whoever interested, you can see all the changes [here](https://github.com/getredash/redash/compare/v6.0.0...master).
|
||||
|
||||
@@ -4,17 +4,18 @@ WORKDIR /frontend
|
||||
COPY package.json package-lock.json /frontend/
|
||||
RUN npm install
|
||||
|
||||
COPY . /frontend
|
||||
COPY client /frontend/client
|
||||
COPY webpack.config.js /frontend/
|
||||
RUN npm run build
|
||||
|
||||
FROM redash/base:latest
|
||||
FROM redash/base:debian
|
||||
|
||||
# Controls whether to install extra dependencies needed for all data sources.
|
||||
ARG skip_ds_deps
|
||||
|
||||
# We first copy only the requirements file, to avoid rebuilding on every file
|
||||
# change.
|
||||
COPY requirements.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
RUN pip install -r requirements.txt -r requirements_dev.txt
|
||||
RUN if [ "x$skip_ds_deps" = "x" ] ; then pip install -r requirements_all_ds.txt ; else echo "Skipping pip install -r requirements_all_ds.txt" ; fi
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<p align="center">
|
||||
<img title="Redash" src='https://redash.io/assets/images/logo.png' width="200px"/>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img title="Build Status" src='https://circleci.com/gh/getredash/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040'/>
|
||||
</p>
|
||||
|
||||
[](https://redash.io/help/)
|
||||
[](https://datree.io/?src=badge)
|
||||
[](https://circleci.com/gh/getredash/redash/tree/master)
|
||||
|
||||
**_Redash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
|
||||
|
||||
|
||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please email security@redash.io to report any security vulnerabilities. We will acknowledge receipt of your vulnerability and strive to send you regular updates about our progress. If you're curious about the status of your disclosure please feel free to email us again. If you want to encrypt your disclosure email, you can use [this PGP key](https://keybase.io/arikfr/key.asc).
|
||||
@@ -1,39 +1,118 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Copy bundle extension files to the client/app/extension directory"""
|
||||
import logging
|
||||
import os
|
||||
from subprocess import call
|
||||
from distutils.dir_util import copy_tree
|
||||
from pathlib2 import Path
|
||||
from shutil import copy
|
||||
from collections import OrderedDict as odict
|
||||
|
||||
from pkg_resources import iter_entry_points, resource_filename, resource_isdir
|
||||
from importlib_metadata import entry_points
|
||||
from importlib_resources import contents, is_resource, path
|
||||
|
||||
# Name of the subdirectory
|
||||
BUNDLE_DIRECTORY = "bundle"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Make a directory for extensions and set it as an environment variable
|
||||
# to be picked up by webpack.
|
||||
EXTENSIONS_RELATIVE_PATH = os.path.join('client', 'app', 'extensions')
|
||||
EXTENSIONS_DIRECTORY = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)),
|
||||
EXTENSIONS_RELATIVE_PATH)
|
||||
extensions_relative_path = Path('client', 'app', 'extensions')
|
||||
extensions_directory = Path(__file__).parent.parent / extensions_relative_path
|
||||
|
||||
if not os.path.exists(EXTENSIONS_DIRECTORY):
|
||||
os.makedirs(EXTENSIONS_DIRECTORY)
|
||||
os.environ["EXTENSIONS_DIRECTORY"] = EXTENSIONS_RELATIVE_PATH
|
||||
if not extensions_directory.exists():
|
||||
extensions_directory.mkdir()
|
||||
os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path)
|
||||
|
||||
for entry_point in iter_entry_points('redash.extensions'):
|
||||
# This is where the frontend code for an extension lives
|
||||
# inside of its package.
|
||||
content_folder_relative = os.path.join(
|
||||
entry_point.name, 'bundle')
|
||||
(root_module, _) = os.path.splitext(entry_point.module_name)
|
||||
|
||||
if not resource_isdir(root_module, content_folder_relative):
|
||||
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")
|
||||
|
||||
|
||||
def load_bundles():
|
||||
""""Load bundles as defined in Redash extensions.
|
||||
|
||||
The bundle entry point can be defined as a dotted path to a module
|
||||
or a callable, but it won't be called but just used as a means
|
||||
to find the files under its file system path.
|
||||
|
||||
The name of the directory it looks for files in is "bundle".
|
||||
|
||||
So a Python package with an extension bundle could look like this::
|
||||
|
||||
my_extensions/
|
||||
├── __init__.py
|
||||
└── wide_footer
|
||||
├── __init__.py
|
||||
└── bundle
|
||||
├── extension.js
|
||||
└── styles.css
|
||||
|
||||
and would then need to register the bundle with an entry point
|
||||
under the "redash.bundles" group, e.g. in your setup.py::
|
||||
|
||||
setup(
|
||||
# ...
|
||||
entry_points={
|
||||
"redash.bundles": [
|
||||
"wide_footer = my_extensions.wide_footer",
|
||||
]
|
||||
# ...
|
||||
},
|
||||
# ...
|
||||
)
|
||||
|
||||
"""
|
||||
bundles = odict()
|
||||
for entry_point in 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):
|
||||
logger.error(
|
||||
'Redash bundle directory "%s" could not be found.', entry_point.name
|
||||
)
|
||||
continue
|
||||
with path(module, BUNDLE_DIRECTORY) as bundle_dir:
|
||||
bundles[entry_point.name] = list(bundle_dir.rglob("*"))
|
||||
|
||||
return bundles
|
||||
|
||||
|
||||
bundles = load_bundles().items()
|
||||
if bundles:
|
||||
print('Number of extension bundles found: {}'.format(len(bundles)))
|
||||
else:
|
||||
print('No extension bundles found.')
|
||||
|
||||
for bundle_name, paths in bundles:
|
||||
# Shortcut in case not paths were found for the bundle
|
||||
if not paths:
|
||||
print('No paths found for bundle "{}".'.format(bundle_name))
|
||||
continue
|
||||
|
||||
content_folder = resource_filename(root_module, content_folder_relative)
|
||||
# The destination for the bundle files with the entry point name as the subdirectory
|
||||
destination = Path(extensions_directory, bundle_name)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
|
||||
# This is where we place our extensions folder.
|
||||
destination = os.path.join(
|
||||
EXTENSIONS_DIRECTORY,
|
||||
entry_point.name)
|
||||
|
||||
copy_tree(content_folder, destination)
|
||||
# Copy the bundle directory from the module to its destination.
|
||||
print('Copying "{}" bundle to {}:'.format(bundle_name, destination.resolve()))
|
||||
for src_path in paths:
|
||||
dest_path = destination / src_path.name
|
||||
print(" - {} -> {}".format(src_path, dest_path))
|
||||
copy(str(src_path), str(dest_path))
|
||||
|
||||
@@ -4,9 +4,10 @@ set -e
|
||||
worker() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
|
||||
WORKER_EXTRA_OPTIONS=${WORKER_EXTRA_OPTIONS:-}
|
||||
|
||||
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
|
||||
exec /usr/local/bin/celery worker --app=redash.worker -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair $WORKER_EXTRA_OPTIONS
|
||||
}
|
||||
|
||||
scheduler() {
|
||||
@@ -16,11 +17,24 @@ scheduler() {
|
||||
|
||||
echo "Starting scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
|
||||
exec /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
|
||||
exec /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
|
||||
}
|
||||
|
||||
dev_worker() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
|
||||
SCHEDULE_DB=${SCHEDULE_DB:-celerybeat-schedule}
|
||||
|
||||
echo "Starting dev scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
|
||||
}
|
||||
|
||||
server() {
|
||||
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app
|
||||
# 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}
|
||||
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER
|
||||
}
|
||||
|
||||
create_db() {
|
||||
@@ -40,6 +54,7 @@ help() {
|
||||
echo "server -- start Redash server (with gunicorn)"
|
||||
echo "worker -- start Celery worker"
|
||||
echo "scheduler -- start Celery worker with a beat (scheduler) process"
|
||||
echo "dev_worker -- start Celery worker with a beat (scheduler) process which picks up code changes and reloads"
|
||||
echo "celery_healthcheck -- runs a Celery healthcheck. Useful for Docker's HEALTHCHECK mechanism."
|
||||
echo ""
|
||||
echo "shell -- open shell"
|
||||
@@ -74,6 +89,10 @@ case "$1" in
|
||||
shift
|
||||
scheduler
|
||||
;;
|
||||
dev_worker)
|
||||
shift
|
||||
dev_worker
|
||||
;;
|
||||
dev_server)
|
||||
export FLASK_DEBUG=1
|
||||
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -o errexit # fail the build if any task fails
|
||||
|
||||
flake8 --version ; pip --version
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
8
bin/pack
8
bin/pack
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
NAME=redash
|
||||
VERSION=$(python ./manage.py version)
|
||||
FULL_VERSION=$VERSION+b$CIRCLE_BUILD_NUM
|
||||
FILENAME=$NAME.$FULL_VERSION.tar.gz
|
||||
|
||||
sed -ri "s/^__version__ = '([A-Za-z0-9.-]*)'/__version__ = '$FULL_VERSION'/" redash/__init__.py
|
||||
tar -zcv -f $FILENAME --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" *
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", {
|
||||
"targets": "> 0.5%, last 2 versions, Firefox ESR, ie 11, not dead",
|
||||
"exclude": [
|
||||
"@babel/plugin-transform-async-to-generator",
|
||||
"@babel/plugin-transform-arrow-functions"
|
||||
],
|
||||
"useBuiltIns": "usage"
|
||||
}],
|
||||
"@babel/preset-react"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
build/*.js
|
||||
config/*.js
|
||||
node_modules
|
||||
client/dist
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["airbnb", "plugin:jest/recommended"],
|
||||
plugins: ["jest", "cypress"],
|
||||
extends: ["airbnb", "plugin:compat/recommended"],
|
||||
plugins: ["jest", "compat", "no-only-tests"],
|
||||
settings: {
|
||||
"import/resolver": "webpack"
|
||||
},
|
||||
parser: "babel-eslint",
|
||||
env: {
|
||||
"jest/globals": true,
|
||||
"cypress/globals": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
browser: true,
|
||||
node: true
|
||||
},
|
||||
rules: {
|
||||
// allow debugger during development
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
|
||||
'no-param-reassign': 0,
|
||||
'no-mixed-operators': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||
"no-param-reassign": 0,
|
||||
"no-mixed-operators": 0,
|
||||
"no-underscore-dangle": 0,
|
||||
"no-use-before-define": ["error", "nofunc"],
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-template": "off",
|
||||
@@ -27,34 +25,42 @@ module.exports = {
|
||||
"no-lonely-if": "off",
|
||||
"consistent-return": "off",
|
||||
"no-control-regex": "off",
|
||||
'no-multiple-empty-lines': 'warn',
|
||||
"no-script-url": "off", // some <a> tags should have href="javascript:void(0)"
|
||||
'operator-linebreak': 'off',
|
||||
'react/destructuring-assignment': 'off',
|
||||
"no-multiple-empty-lines": "warn",
|
||||
"no-only-tests/no-only-tests": "error",
|
||||
"operator-linebreak": "off",
|
||||
"react/destructuring-assignment": "off",
|
||||
"react/jsx-filename-extension": "off",
|
||||
'react/jsx-one-expression-per-line': '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/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-associated-control": [
|
||||
"warn",
|
||||
{
|
||||
controlComponents: true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/label-has-for": "off",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"max-len": ['error', 120, 2, {
|
||||
"max-len": [
|
||||
"error",
|
||||
120,
|
||||
2,
|
||||
{
|
||||
ignoreUrls: true,
|
||||
ignoreComments: false,
|
||||
ignoreRegExpLiterals: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
}],
|
||||
"no-else-return": ["error", {"allowElseIf": true}],
|
||||
"object-curly-newline": ["error", {"consistent": true}],
|
||||
ignoreTemplateLiterals: true
|
||||
}
|
||||
],
|
||||
"no-else-return": ["error", { allowElseIf: true }],
|
||||
"object-curly-newline": ["error", { consistent: true }]
|
||||
}
|
||||
};
|
||||
|
||||
10
client/app/.eslintrc.js
Normal file
10
client/app/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
extends: ["plugin:jest/recommended"],
|
||||
plugins: ["jest"],
|
||||
env: {
|
||||
"jest/globals": true,
|
||||
},
|
||||
rules: {
|
||||
"jest/no-focused-tests": "off",
|
||||
},
|
||||
};
|
||||
BIN
client/app/assets/images/db-logos/azure_kusto.png
Normal file
BIN
client/app/assets/images/db-logos/azure_kusto.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
client/app/assets/images/db-logos/bigquery_gce.png
Normal file
BIN
client/app/assets/images/db-logos/bigquery_gce.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
client/app/assets/images/db-logos/couchbase.png
Normal file
BIN
client/app/assets/images/db-logos/couchbase.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
client/app/assets/images/db-logos/dgraph.png
Normal file
BIN
client/app/assets/images/db-logos/dgraph.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
client/app/assets/images/db-logos/json.png
Normal file
BIN
client/app/assets/images/db-logos/json.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
client/app/assets/images/db-logos/phoenix.png
Normal file
BIN
client/app/assets/images/db-logos/phoenix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -20,7 +20,10 @@
|
||||
@import '~antd/lib/tag/style/index';
|
||||
@import '~antd/lib/grid/style/index';
|
||||
@import '~antd/lib/switch/style/index';
|
||||
@import '~antd/lib/empty/style/index';
|
||||
@import '~antd/lib/drawer/style/index';
|
||||
@import '~antd/lib/card/style/index';
|
||||
@import '~antd/lib/steps/style/index';
|
||||
@import '~antd/lib/divider/style/index';
|
||||
@import '~antd/lib/dropdown/style/index';
|
||||
@import '~antd/lib/menu/style/index';
|
||||
@@ -29,8 +32,28 @@
|
||||
@import "~antd/lib/card/style/index";
|
||||
@import "~antd/lib/spin/style/index";
|
||||
@import "~antd/lib/tabs/style/index";
|
||||
@import "~antd/lib/notification/style/index";
|
||||
@import "~antd/lib/collapse/style/index";
|
||||
@import "~antd/lib/progress/style/index";
|
||||
@import "~antd/lib/typography/style/index";
|
||||
@import 'inc/ant-variables';
|
||||
|
||||
// Increase z-indexes to avoid conflicts with some other libraries (e.g. Plotly)
|
||||
@zindex-modal: 2000;
|
||||
@zindex-modal-mask: 2000;
|
||||
@zindex-message: 2010;
|
||||
@zindex-notification: 2010;
|
||||
@zindex-popover: 2030;
|
||||
@zindex-dropdown: 2050;
|
||||
@zindex-picker: 2050;
|
||||
@zindex-tooltip: 2060;
|
||||
|
||||
.@{drawer-prefix-cls} {
|
||||
&.help-drawer {
|
||||
z-index: @zindex-tooltip; // help drawer should be topmost
|
||||
}
|
||||
}
|
||||
|
||||
// Remove bold in labels for Ant checkboxes and radio buttons
|
||||
.ant-checkbox-wrapper,
|
||||
.ant-radio-wrapper {
|
||||
@@ -54,11 +77,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for Ant dropdowns when they are used in Boootstrap modals
|
||||
.ant-dropdown-in-bootstrap-modal {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
// Button overrides
|
||||
.@{btn-prefix-cls} {
|
||||
transition-duration: 150ms;
|
||||
@@ -132,6 +150,10 @@
|
||||
border-color: transparent;
|
||||
color: @pagination-color;
|
||||
line-height: @pagination-item-size - 2px;
|
||||
|
||||
.@{pagination-prefix-cls}.mini & {
|
||||
line-height: @pagination-item-size-sm - 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus .@{pagination-prefix-cls}-item-link,
|
||||
@@ -217,23 +239,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
// styling for short modals (no lines)
|
||||
.@{dialog-prefix-cls}.shortModal {
|
||||
.@{dialog-prefix-cls} {
|
||||
// styling for short modals (no lines)
|
||||
&.shortModal {
|
||||
.@{dialog-prefix-cls} {
|
||||
&-header,
|
||||
&-footer {
|
||||
border: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
&-close-x {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fullscreen modals
|
||||
&-fullscreen {
|
||||
.@{dialog-prefix-cls} {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
bottom: 15px;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.@{dialog-prefix-cls}-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.@{dialog-prefix-cls}-body {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// description in modal header
|
||||
@@ -244,6 +306,60 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ant-popover {
|
||||
z-index: 1000; // make sure it doesn't cover drawer
|
||||
// Notification overrides
|
||||
.@{notification-prefix-cls} {
|
||||
// vertical centering
|
||||
&-notice-close {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
&-notice-description {
|
||||
max-width: 484px;
|
||||
}
|
||||
}
|
||||
|
||||
.@{btn-prefix-cls} .@{iconfont-css-prefix}-ellipsis {
|
||||
margin: 0 -7px;
|
||||
}
|
||||
|
||||
// Collapse
|
||||
|
||||
.@{collapse-prefix-cls} {
|
||||
&&-headerless {
|
||||
border: 0;
|
||||
background: none;
|
||||
|
||||
.@{collapse-prefix-cls}-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.@{collapse-prefix-cls}-item,
|
||||
.@{collapse-prefix-cls}-content {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.@{collapse-prefix-cls}-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overrides for tall form components such as ace editor
|
||||
.@{form-prefix-cls}-item {
|
||||
&-children {
|
||||
display: block; // so feeback icon positions correctly
|
||||
}
|
||||
|
||||
// no change for short components, sticks to body for tall ones
|
||||
&-children-icon {
|
||||
top: auto !important;
|
||||
bottom: 8px;
|
||||
|
||||
// makes the icon white instead of see-through
|
||||
& svg {
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
.alert {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
padding: 15px;
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
@font-size-base: 13px;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Borders
|
||||
-----------------------------------------------------------*/
|
||||
@border-color-split: #f0f0f0;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Typograpgy
|
||||
-----------------------------------------------------------*/
|
||||
@@ -72,3 +78,9 @@
|
||||
@table-row-hover-bg: fade(@redash-gray, 5%);
|
||||
@table-padding-vertical: 7px;
|
||||
@table-padding-horizontal: 10px;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Notification
|
||||
-----------------------------------------------------------*/
|
||||
@notification-padding: @notification-padding-vertical 48px @notification-padding-vertical 17px;
|
||||
@notification-width: auto;
|
||||
|
||||
@@ -19,21 +19,30 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: @header-height;
|
||||
position: relative;
|
||||
padding-bottom: @footer-height;
|
||||
&.headless {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
.nav.app-header {
|
||||
display: none;
|
||||
}
|
||||
div#footer {
|
||||
background: #F6F8F9;
|
||||
font-family: @redash-font;
|
||||
position: relative;
|
||||
|
||||
&.headless {
|
||||
padding-top: 10px;
|
||||
|
||||
.nav.app-header, .navbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app-view {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
app-view, #app-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -67,10 +76,34 @@ strong {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed width layout for specific pages
|
||||
@media (min-width: 768px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 750px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 970px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 1170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbox {
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
|
||||
}
|
||||
|
||||
.clickable {
|
||||
@@ -90,3 +123,150 @@ strong {
|
||||
resize: both !important;
|
||||
transition: height 0s, width 0s !important;
|
||||
}
|
||||
|
||||
// Ace Editor
|
||||
.ace_editor {
|
||||
border: 1px solid fade(@redash-gray, 15%) !important;
|
||||
}
|
||||
|
||||
.ace-tm {
|
||||
.ace_gutter {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ace_gutter-active-line {
|
||||
background-color: fade(@redash-gray, 20%) !important;
|
||||
}
|
||||
|
||||
.ace_marker-layer .ace_active-line {
|
||||
background: fade(@redash-gray, 9%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-ace {
|
||||
background-color: fade(@redash-gray, 12%) !important;
|
||||
}
|
||||
|
||||
// resizeable
|
||||
.rg-top span, .rg-bottom span {
|
||||
height: 3px;
|
||||
border-color: #b1c1ce; // TODO: variable
|
||||
}
|
||||
|
||||
.rg-bottom {
|
||||
bottom: 15px;
|
||||
|
||||
span {
|
||||
margin: 1.5px 0 0 -10px;
|
||||
}
|
||||
}
|
||||
|
||||
// Plotly
|
||||
text.slicetext {
|
||||
text-shadow: 1px 1px 5px #333;
|
||||
}
|
||||
|
||||
// markdown
|
||||
.markdown strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.profile__image--navbar {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.profile__image--settings {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.profile__image_thumb {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
// Error state
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
margin-top: 25vh;
|
||||
padding: 35px;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
|
||||
.error-state__icon {
|
||||
.zmdi {
|
||||
font-size: 64px;
|
||||
color: @redash-gray;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
}
|
||||
|
||||
// page
|
||||
.page-header--new .btn-favourite, .page-header--new .btn-archive {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
display: block;
|
||||
|
||||
favorites-control {
|
||||
float: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
width: 100%;
|
||||
margin-bottom: 5px !important;
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-wrapper, .page-header--new {
|
||||
h3 {
|
||||
margin: 0.2em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.select-option-divider {
|
||||
margin: 10px 0 !important;
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
.collapsing,
|
||||
.collapse.in {
|
||||
padding: 5px 10px;
|
||||
padding: 0;
|
||||
transition: all 0.35s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,3 +122,21 @@
|
||||
top: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.btn-default {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
}
|
||||
|
||||
.btn-transparent {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-default:hover, .btn-default:focus, .btn-default.focus, .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default {
|
||||
background-color: fade(@redash-gray, 25%);
|
||||
}
|
||||
|
||||
.btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus {
|
||||
color: #333;
|
||||
background-color: fade(@redash-gray, 45%);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
#footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: @footer-height;
|
||||
color: #a2a2a2;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 15px;
|
||||
|
||||
.f-menu {
|
||||
display: block;
|
||||
width: 100%;
|
||||
.list-inline();
|
||||
margin-top: 8px;
|
||||
|
||||
& > li > a {
|
||||
color: #a2a2a2;
|
||||
|
||||
&:hover {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: (@screen-lg-min + 80px)) {
|
||||
padding-left: (@sidebar-left-width + @grid-gutter-width);
|
||||
}
|
||||
|
||||
@media (min-width: @screen-sm-min) and (max-width: (@screen-md-max + 80px)) {
|
||||
padding-left: (@sidebar-left-mid-width + @grid-gutter-width);
|
||||
}
|
||||
|
||||
@media (max-width: (@screen-sm-min)) {
|
||||
padding-left: @grid-gutter-width/2;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: #818d9f;
|
||||
padding-bottom: 30px;
|
||||
a {
|
||||
color: #818d9f;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,14 +55,17 @@ textarea.v-resizable {
|
||||
.transition-duration(300ms);
|
||||
resize: none;
|
||||
box-shadow: 0 0 0 40px rgba(0, 0, 0, 0) !important;
|
||||
border-radius: 0;
|
||||
border-radius: @redash-input-radius;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 1px -2px rgba(121,194,255,0.5) !important;
|
||||
box-shadow: none !important;
|
||||
border-color: @blue;
|
||||
}
|
||||
&:hover {
|
||||
border-color: @blue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Custom Checkbox + Radio
|
||||
-----------------------------------------------------------*/
|
||||
|
||||
@@ -154,3 +154,9 @@
|
||||
Border Radius
|
||||
-----------------------------------------------------------*/
|
||||
.brd-2 { border-radius: 2px; }
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Alignment
|
||||
-----------------------------------------------------------*/
|
||||
.va-top { vertical-align: top; }
|
||||
@@ -1,14 +1,37 @@
|
||||
.label {
|
||||
border-radius: 1px;
|
||||
padding: 4px 5px 3px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
.label {
|
||||
border-radius: 2px;
|
||||
}
|
||||
padding: 3px 6px 4px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.label-default {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-archived {
|
||||
.label-warning();
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished,
|
||||
.label-tag-archived,
|
||||
.label-tag {
|
||||
margin-right: 3px;
|
||||
display: inline;
|
||||
margin-top: 2px;
|
||||
max-width: 24ch;
|
||||
.text-overflow();
|
||||
}
|
||||
@@ -31,6 +31,17 @@ tags-list {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
.badge-light {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.max-character {
|
||||
.text-overflow();
|
||||
}
|
||||
@@ -45,6 +56,11 @@ tags-list {
|
||||
line-height: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.active, &.active:hover, &.active:focus {
|
||||
background-color: #fff;
|
||||
box-shadow: inset 3px 0px 0px @brand-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item-heading {
|
||||
@@ -76,3 +92,18 @@ tags-list {
|
||||
height: 38px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -226,3 +226,12 @@
|
||||
border-radius: 2px;
|
||||
width: 37px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Percy
|
||||
-----------------------------------------------------------*/
|
||||
@media only percy {
|
||||
.hide-in-percy, .pace {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@@ -30,3 +30,266 @@ a.navbar-brand img {
|
||||
left: -9px;
|
||||
bottom: -11px;
|
||||
}
|
||||
|
||||
.caret--nav {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.caret--nav:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 9px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: block;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.296,4.288 L9.382,0.2 C9.66086822,-0.0716916976 10.1065187,-0.068122925 10.381,0.208 C10.661,0.488 10.661,0.932 10.388,1.206 L5.792,5.803 C5.6602899,5.93388911 5.48167943,6.00662966 5.296,6.005 C5.10997499,6.00689786 4.93095449,5.93413702 4.799,5.803 L0.204,1.207 C0.072163111,1.07394937 -0.00121750401,0.893846387 9.62313189e-05,0.706545264 C0.00140996665,0.519244142 0.0773097323,0.340188219 0.211,0.209 C0.485365732,-0.0664648737 0.930253538,-0.0700311086 1.209,0.201 L5.296,4.288 L5.296,4.288 Z' id='Shape' fill='%23000000'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
|
||||
background-size: 100% 100%;
|
||||
transition: transform .2s cubic-bezier(.75,0,.25,1);
|
||||
}
|
||||
|
||||
.navbar .caret--nav:after {
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
.dropdown--profile .caret--nav:after {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.btn--create {
|
||||
padding-right: 20px;
|
||||
|
||||
.caret--nav:after {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.29592111,4.28945339 L9.38192111,0.201453387 C9.66078932,-0.0702383105 10.1064398,-0.0666695379 10.3809211,0.209453387 C10.6609211,0.489453387 10.6609211,0.933453387 10.3879211,1.20745339 L5.79192111,5.80445339 C5.66021101,5.9353425 5.48160054,6.00808305 5.29592111,6.00645339 C5.1098961,6.00835125 4.9308756,5.9355904 4.79892111,5.80445339 L0.203921109,1.20845339 C0.0720842204,1.07540275 -0.00129639464,0.895299774 1.73406884e-05,0.707998651 C0.00133107602,0.520697529 0.0772308417,0.341641606 0.210921109,0.210453387 C0.485286842,-0.0650114866 0.930174648,-0.0685777215 1.20892111,0.202453387 L5.29592111,4.28945339 L5.29592111,4.28945339 Z' id='Shape' fill='%23FCFCFC'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown.open .caret--nav:after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
.navbar-collapse {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
a.dropdown--profile {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
line-height: 2.35;
|
||||
}
|
||||
|
||||
.navbar-inverse {
|
||||
background-color: @redash-gray;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -25px !important; // center
|
||||
display: block;
|
||||
zoom: 0.9;
|
||||
}
|
||||
|
||||
.menu-search {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar .collapse.in {
|
||||
background: #fff;
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
.navbar {
|
||||
min-height: initial;
|
||||
height: 50px;
|
||||
border: 1px solid #fff;
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.btn-group.open .dropdown-toggle {
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-group .btn:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&:active, &:hover, &:focus {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-default .btn__new button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn__new {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > li > a:hover {
|
||||
//background-color: fade(@redash-gray, 10%);
|
||||
//text-decoration: underline;
|
||||
//border-radius: 0;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a:hover, .navbar-default .navbar-nav > .open > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
|
||||
// Responsive fixes
|
||||
@media (max-width: 767px) {
|
||||
.navbar-brand {
|
||||
left: 2%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
//Fix navbar collapse
|
||||
.navbar .collapse.in {
|
||||
border: none;
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--profile {
|
||||
.caret--nav:after {
|
||||
right: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--profile__username {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav__main li a {
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
text-align: left;
|
||||
float: none !important;
|
||||
}
|
||||
|
||||
.navbar-form {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 810px) {
|
||||
.menu-search {
|
||||
width: 175px;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
margin-left: 13px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1084px) {
|
||||
.dropdown--profile__username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Cross-browser fixes
|
||||
|
||||
// Firefox
|
||||
@-moz-document url-prefix() {
|
||||
.caret--nav::after {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.navbar .caret--nav::after {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.navbar .btn--create .caret--nav::after {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// IE10+
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.caret--nav::after {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.navbar .caret--nav::after {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.navbar .btn--create .caret--nav::after {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa {
|
||||
font-size: 100%;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.pagination {
|
||||
border-radius: 0;
|
||||
|
||||
& > li {
|
||||
margin: 0 2px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
& > a,
|
||||
& > span {
|
||||
border-radius: 50% !important;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
|
||||
& > .zmdi {
|
||||
font-size: 22px;
|
||||
line-height: 39px;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.opacity(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Listview Pagination
|
||||
-----------------------------------------------------------*/
|
||||
.lv-pagination {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Pager
|
||||
-----------------------------------------------------------*/
|
||||
.pager li > a, .pager li > span {
|
||||
padding: 5px 10px 6px;
|
||||
color: @pagination-color;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.popover {
|
||||
box-shadow: 0 2px 30px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px;
|
||||
}
|
||||
|
||||
.popover-title {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
|
||||
#header,
|
||||
#footer,
|
||||
#sidebar,
|
||||
#chat,
|
||||
.growl-animated,
|
||||
|
||||
@@ -132,9 +132,15 @@
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
margin-bottom: 0px;
|
||||
|
||||
> li.rd-tab-btn {
|
||||
float: right;
|
||||
padding-right: 10px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
> li > a {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,3 +97,53 @@
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.table-data {
|
||||
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;
|
||||
}
|
||||
|
||||
.btn-favourite {
|
||||
color: #d4d4d4;
|
||||
transition: all .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;
|
||||
}
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.table-data .label-tag {
|
||||
display: inline-block;
|
||||
max-width: 135px;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
background-color: #fff;
|
||||
margin-bottom: @grid-gutter-width;
|
||||
position: relative;
|
||||
box-shadow: @tile-shadow;
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
&[class*="bg-"] {
|
||||
color: #fff;
|
||||
@@ -12,6 +13,10 @@
|
||||
margin-bottom: @grid-gutter-width/2;
|
||||
}
|
||||
}
|
||||
.tiled {
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
}
|
||||
|
||||
.t-header {
|
||||
.th-title {
|
||||
@@ -74,6 +79,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.t-header:not(.th-alt) {
|
||||
padding: 15px;
|
||||
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
line-height: 2.2;
|
||||
}
|
||||
}
|
||||
|
||||
.tb-padding {
|
||||
padding: 20px 23px 30px;
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
Template Variables
|
||||
-----------------------------------------------------------*/
|
||||
@header-height: 60px;
|
||||
@footer-height: 95px;
|
||||
@sidebar-left-width: 240px;
|
||||
@sidebar-left-mid-width: 64px;
|
||||
@logo-width: @sidebar-left-width;
|
||||
@logo-height: @header-height;
|
||||
@boxed-width: 1170px;
|
||||
@body-bg: #edecec;
|
||||
@spacing: 15px;
|
||||
@redash-radius: 3px;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
@@ -40,6 +41,7 @@
|
||||
-----------------------------------------------------------*/
|
||||
@font-icon: 'Material-Design-Iconic-Font';
|
||||
@font-family-sans-serif: 'Roboto', sans-serif;
|
||||
@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
@font-size-base: 13px;
|
||||
|
||||
|
||||
@@ -60,6 +62,7 @@
|
||||
@input-border: #e8e8e8;
|
||||
@input-border-radius: 0;
|
||||
@input-border-radius-large: 0px;
|
||||
@redash-input-radius: 2px;
|
||||
@input-height-large: 40px;
|
||||
@input-height-base: 35px;
|
||||
@input-height-small: 30px;
|
||||
@@ -95,6 +98,11 @@
|
||||
@gray-light: #828282;
|
||||
@ace: #f8f8f8;
|
||||
|
||||
@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);
|
||||
|
||||
/** Form States **/
|
||||
@state-success-text: @green;
|
||||
@state-info-text: @blue;
|
||||
@@ -193,7 +201,6 @@
|
||||
@pagination-hover-color: #333;
|
||||
@pagination-hover-bg: #d7d7d7;
|
||||
@pagination-hover-border: @pagination-border;
|
||||
@pager-border-radius: 5px;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
counter-renderer {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 15px 10px;
|
||||
overflow: hidden;
|
||||
|
||||
counter {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 80px;
|
||||
line-height: normal;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
value,
|
||||
counter-target {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
counter-name {
|
||||
font-size: 0.5em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.positive value {
|
||||
color: #5cb85c;
|
||||
}
|
||||
|
||||
&.negative value {
|
||||
color: #d9534f;
|
||||
}
|
||||
}
|
||||
|
||||
counter-target {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
counter-name {
|
||||
font-size: 0.5em;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
visualization-renderer {
|
||||
display: block;
|
||||
|
||||
.pagination,
|
||||
.ant-pagination {
|
||||
margin: 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
.pivot-table-renderer > table,
|
||||
visualization-renderer > .visualization-renderer-wrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
/** Load Vendors Dependencies **/
|
||||
@import '~font-awesome/less/font-awesome';
|
||||
@import '~ui-select/dist/select.css';
|
||||
@import '~angular-toastr/src/toastr';
|
||||
@import '~angular-resizable/src/angular-resizable.css';
|
||||
@import '~material-design-iconic-font/dist/css/material-design-iconic-font.css';
|
||||
@import '~pace-progress/themes/blue/pace-theme-minimal.css';
|
||||
@@ -33,7 +32,6 @@
|
||||
@import 'inc/progress-bar';
|
||||
@import 'inc/widgets';
|
||||
@import 'inc/table';
|
||||
@import 'inc/pagination';
|
||||
@import 'inc/alert';
|
||||
@import 'inc/media';
|
||||
@import 'inc/modal';
|
||||
@@ -45,7 +43,6 @@
|
||||
@import 'inc/jumbotron';
|
||||
@import 'inc/profile';
|
||||
@import 'inc/404';
|
||||
@import 'inc/footer';
|
||||
@import 'inc/ie-warning';
|
||||
@import 'inc/navbar';
|
||||
@import 'inc/edit-in-place';
|
||||
@@ -56,11 +53,9 @@
|
||||
@import 'inc/schema-browser';
|
||||
@import 'inc/toast';
|
||||
@import 'inc/visualizations/box';
|
||||
@import 'inc/visualizations/counter-render';
|
||||
@import 'inc/visualizations/sankey';
|
||||
@import 'inc/visualizations/pivot-table';
|
||||
@import 'inc/visualizations/map';
|
||||
@import 'inc/visualizations/chart';
|
||||
@import 'inc/visualizations/sunburst';
|
||||
@import 'inc/visualizations/cohort';
|
||||
@import 'inc/visualizations/misc';
|
||||
@@ -73,10 +68,11 @@
|
||||
@import 'inc/vendor-overrides/ui-select';
|
||||
|
||||
/** REDASH STYLING **/
|
||||
@import 'redash/redash-newstyle';
|
||||
@import 'redash/redash-table';
|
||||
@import 'redash/query';
|
||||
@import 'redash/tags-control';
|
||||
@import 'redash/css-logo';
|
||||
@import 'redash/loading-indicator';
|
||||
|
||||
|
||||
|
||||
|
||||
88
client/app/assets/less/redash/css-logo.less
Normal file
88
client/app/assets/less/redash/css-logo.less
Normal file
@@ -0,0 +1,88 @@
|
||||
// based on https://github.com/outbrain/tech-companies-logos-in-css/pull/28
|
||||
|
||||
@primary: #ff7964;
|
||||
@shadow: #ef6c58;
|
||||
@bar: white;
|
||||
|
||||
#css-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
|
||||
#circle {
|
||||
width: 79px;
|
||||
height: 79px;
|
||||
background-color: @shadow;
|
||||
border-radius: 50%;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
& > div {
|
||||
width: 79px;
|
||||
height: 73px;
|
||||
background-color: @primary;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#bars {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 24px;
|
||||
right: 0;
|
||||
height: 33px;
|
||||
display: flex;
|
||||
padding: 0 22px 0;
|
||||
|
||||
.bar {
|
||||
background: @bar;
|
||||
box-shadow: 0px 2px 0 0 @shadow;
|
||||
display: inline-block;
|
||||
border-radius: 1px;
|
||||
align-self: flex-end;
|
||||
flex: 1;
|
||||
margin: 0 2px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:nth-child(1) {
|
||||
height: 32%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
height: 71%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#point,
|
||||
#point > div {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 17px solid @shadow;
|
||||
border-right-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
bottom: 0;
|
||||
left: 48px;
|
||||
transform: scaleX(0.87);
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
#point > div {
|
||||
bottom: -12px;
|
||||
border-color: @primary;
|
||||
transform: scaleX(1.04);
|
||||
left: -17px;
|
||||
}
|
||||
}
|
||||
51
client/app/assets/less/redash/loading-indicator.less
Normal file
51
client/app/assets/less/redash/loading-indicator.less
Normal file
@@ -0,0 +1,51 @@
|
||||
.loading-indicator {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -50px 0 0 -50px; // center
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: linear;
|
||||
transition-property: opacity, transform;
|
||||
|
||||
#css-logo {
|
||||
animation: hover 2s infinite;
|
||||
}
|
||||
|
||||
#shadow {
|
||||
width: 33px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: black;
|
||||
opacity: 0.25;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 34px;
|
||||
top: 115px;
|
||||
animation: shadow 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes hover {
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
@keyframes shadow {
|
||||
50% {
|
||||
transform: scaleX(0.9);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hide indicator when app-view has content
|
||||
app-view:not(:empty) ~ .loading-indicator {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
|
||||
* {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ edit-in-place p.editable:hover {
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_completion-highlight {
|
||||
@@ -172,6 +172,12 @@ edit-in-place p.editable:hover {
|
||||
}
|
||||
}
|
||||
|
||||
.query-log-line {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.paginator-container {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -202,21 +208,21 @@ edit-in-place p.editable:hover {
|
||||
}
|
||||
}
|
||||
|
||||
.visualization-renderer {
|
||||
.pagination,
|
||||
.ant-pagination {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.embed__vis {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.embed-heading {
|
||||
h3 {
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.widget-wrapper {
|
||||
.body-container {
|
||||
filters {
|
||||
.filters-wrapper {
|
||||
display: block;
|
||||
padding-left: 15px;
|
||||
}
|
||||
@@ -258,6 +264,7 @@ a.label-tag {
|
||||
.query-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.query-fullscreen {
|
||||
@@ -336,7 +343,8 @@ a.label-tag {
|
||||
border-bottom: 1px solid #efefef;
|
||||
}
|
||||
|
||||
pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
.pivot-table-renderer > table,
|
||||
visualization-renderer > .visualization-renderer-wrapper {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -551,6 +559,10 @@ nav .rg-bottom {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.edit-visualization {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
// Smaller screens
|
||||
|
||||
@media (max-width: 880px) {
|
||||
@@ -665,8 +677,17 @@ nav .rg-bottom {
|
||||
.filter-container {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-edit-visualisation {
|
||||
// Responsive fixes
|
||||
@media (max-width: 767px) {
|
||||
.query-page-wrapper {
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,978 +0,0 @@
|
||||
@import (reference, less) '~bootstrap/less/labels.less';
|
||||
|
||||
// Variables
|
||||
@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);
|
||||
@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
|
||||
@spacing: 15px;
|
||||
|
||||
//Default spacing (between tiles)
|
||||
@redash-space: 10px;
|
||||
|
||||
@redash-radius: 3px;
|
||||
@redash-input-radius: 2px;
|
||||
|
||||
// General
|
||||
body {
|
||||
padding-top: 0;
|
||||
background: #F6F8F9;
|
||||
font-family: @redash-font;
|
||||
|
||||
&.headless {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 15px;
|
||||
|
||||
.navbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
div#footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.word-wrap-break {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.clearboth {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 20px;
|
||||
border: 1px solid #eee;
|
||||
border-left-width: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
border-left-color: #aa6708;
|
||||
}
|
||||
|
||||
.callout-info {
|
||||
border-left-color: #1b809e;
|
||||
}
|
||||
|
||||
// Fixed width layout for specific pages
|
||||
@media (min-width: 768px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 750px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 970px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 1170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.creation-container {
|
||||
h5 {
|
||||
color: #a7a7a7;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-widget-container {
|
||||
background: #fff;
|
||||
border-radius: @redash-radius;
|
||||
padding: 15px;
|
||||
position: fixed;
|
||||
left: 15px;
|
||||
bottom: 20px;
|
||||
width: calc(~'100% - 30px');
|
||||
z-index: 99;
|
||||
box-shadow: fade(@redash-gray, 50%) 0px 7px 29px -3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 2.1;
|
||||
font-weight: 400;
|
||||
|
||||
.zmdi {
|
||||
margin: 0;
|
||||
margin-right: 5px;
|
||||
font-size: 24px;
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
padding-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
.ace-tm .ace_gutter {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ace_editor {
|
||||
border: 1px solid fade(@redash-gray, 15%);
|
||||
}
|
||||
|
||||
.ace-tm .ace_gutter-active-line {
|
||||
background-color: fade(@redash-gray, 20%);
|
||||
}
|
||||
|
||||
.ace-tm .ace_marker-layer .ace_active-line {
|
||||
background: fade(@redash-gray, 9%);
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item.active, .list-group-item.active:hover, .list-group-item.active:focus {
|
||||
background-color: #fff;
|
||||
box-shadow: inset 3px 0px 0px @brand-primary;
|
||||
}
|
||||
|
||||
.table-data {
|
||||
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;
|
||||
a {
|
||||
//font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-favourite, .btn-archive {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @yellow-darker;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header--new .btn-favourite, .page-header--new .btn-archive {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
display: block;
|
||||
|
||||
favorites-control {
|
||||
float: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
width: 100%;
|
||||
margin-bottom: 5px !important;
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.database-source {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.visual-card {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid fade(@redash-gray, 15%);
|
||||
border-radius: 3px;
|
||||
margin: 5px;
|
||||
width: 212px;
|
||||
padding: 15px 5px;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
transition: transform 0.12s ease-out;
|
||||
transition-duration: 0.3s;
|
||||
transition-property: box-shadow;
|
||||
|
||||
display: flex;
|
||||
//flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
box-shadow: rgba(102, 136, 153, 0.15) 0px 4px 9px -3px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
color: #323232;
|
||||
margin: 0 !important;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-card--selected {
|
||||
background: fade(@redash-gray, 3%);
|
||||
border: 1px solid fade(@redash-gray, 15%);
|
||||
border-radius: 3px;
|
||||
padding: 0 15px;
|
||||
box-shadow: none;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-around;
|
||||
margin-bottom: 15px;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.visual-card {
|
||||
width: 217px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 755px) {
|
||||
.visual-card {
|
||||
width: 47%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 515px) {
|
||||
.visual-card {
|
||||
width: 47%;
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 408px) {
|
||||
.visual-card {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
margin: 5px 0;
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.t-header:not(.th-alt) {
|
||||
padding: 15px;
|
||||
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
line-height: 2.2;
|
||||
}
|
||||
}
|
||||
|
||||
#footer {
|
||||
height: auto;
|
||||
line-height: 3;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header-wrapper, .page-header--new {
|
||||
h3 {
|
||||
margin: 0.2em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.dynamic-table__pagination {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.rg-top span, .rg-bottom span {
|
||||
height: 3px;
|
||||
border-color: #b1c1ce; // TODO: variable
|
||||
}
|
||||
|
||||
.rg-bottom {
|
||||
bottom: 15px;
|
||||
|
||||
span {
|
||||
margin: 1.5px 0 0 -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px;
|
||||
}
|
||||
|
||||
.tile__bottom-control a {
|
||||
color: fade(@redash-black, 65%);
|
||||
|
||||
&:hover {
|
||||
color: fade(@redash-black, 95%);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
.disabled a {
|
||||
background-color: fade(@redash-gray, 14%);
|
||||
}
|
||||
|
||||
li {
|
||||
a {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
|
||||
&:hover {
|
||||
background-color: fade(@redash-gray, 25%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
}
|
||||
|
||||
.btn-transparent {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-default:hover, .btn-default:focus, .btn-default.focus, .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default {
|
||||
background-color: fade(@redash-gray, 25%);
|
||||
}
|
||||
|
||||
.btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus {
|
||||
color: #333;
|
||||
background-color: fade(@redash-gray, 45%);
|
||||
}
|
||||
|
||||
.label {
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px 4px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.label-default {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-archived {
|
||||
.label-warning();
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished,
|
||||
.label-tag-archived,
|
||||
.label-tag {
|
||||
margin-right: 3px;
|
||||
display: inline;
|
||||
margin-top: 2px;
|
||||
max-width: 24ch;
|
||||
.text-overflow();
|
||||
}
|
||||
|
||||
.tab-nav > li > a {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
position: -webkit-sticky; // required for Safari
|
||||
position: sticky;
|
||||
background: #f6f7f9;
|
||||
z-index: 99;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.dashboard__control {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.editing-mode {
|
||||
a.query-link {
|
||||
pointer-events: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.th-title {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
position: -webkit-sticky; // required for Safari
|
||||
position: sticky;
|
||||
background: #f6f7f9;
|
||||
z-index: 99;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.widget-wrapper {
|
||||
.parameter-container {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-ace {
|
||||
background-color: fade(@redash-gray, 12%) !important;
|
||||
}
|
||||
|
||||
.tiled {
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
.widget-menu-regular, .btn__refresh {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
}
|
||||
|
||||
.t-header {
|
||||
.th-title {
|
||||
a {
|
||||
color: fade(@redash-black, 80%);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.query--description {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.t-header.widget {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.widget-menu-regular, .btn__refresh {
|
||||
opacity: 1 !important;
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.tile__bottom-control {
|
||||
padding: 10px 15px;
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-heading {
|
||||
h3 {
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
// Navigation
|
||||
.caret--nav {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.caret--nav:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 9px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: block;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.296,4.288 L9.382,0.2 C9.66086822,-0.0716916976 10.1065187,-0.068122925 10.381,0.208 C10.661,0.488 10.661,0.932 10.388,1.206 L5.792,5.803 C5.6602899,5.93388911 5.48167943,6.00662966 5.296,6.005 C5.10997499,6.00689786 4.93095449,5.93413702 4.799,5.803 L0.204,1.207 C0.072163111,1.07394937 -0.00121750401,0.893846387 9.62313189e-05,0.706545264 C0.00140996665,0.519244142 0.0773097323,0.340188219 0.211,0.209 C0.485365732,-0.0664648737 0.930253538,-0.0700311086 1.209,0.201 L5.296,4.288 L5.296,4.288 Z' id='Shape' fill='%23000000'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
|
||||
background-size: 100% 100%;
|
||||
transition: transform .2s cubic-bezier(.75,0,.25,1);
|
||||
}
|
||||
|
||||
.navbar .caret--nav:after {
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
.dropdown--profile .caret--nav:after {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.btn--create {
|
||||
padding-right: 20px;
|
||||
|
||||
.caret--nav:after {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.29592111,4.28945339 L9.38192111,0.201453387 C9.66078932,-0.0702383105 10.1064398,-0.0666695379 10.3809211,0.209453387 C10.6609211,0.489453387 10.6609211,0.933453387 10.3879211,1.20745339 L5.79192111,5.80445339 C5.66021101,5.9353425 5.48160054,6.00808305 5.29592111,6.00645339 C5.1098961,6.00835125 4.9308756,5.9355904 4.79892111,5.80445339 L0.203921109,1.20845339 C0.0720842204,1.07540275 -0.00129639464,0.895299774 1.73406884e-05,0.707998651 C0.00133107602,0.520697529 0.0772308417,0.341641606 0.210921109,0.210453387 C0.485286842,-0.0650114866 0.930174648,-0.0685777215 1.20892111,0.202453387 L5.29592111,4.28945339 L5.29592111,4.28945339 Z' id='Shape' fill='%23FCFCFC'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown.open .caret--nav:after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapsing, .collapse.in {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
min-height: initial;
|
||||
height: 50px;
|
||||
border: 1px solid #fff;
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.btn-group.open .dropdown-toggle {
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-group .btn:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&:active, &:hover, &:focus {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-default .btn__new button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > li > a:hover {
|
||||
//background-color: fade(@redash-gray, 10%);
|
||||
//text-decoration: underline;
|
||||
//border-radius: 0;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a:hover, .navbar-default .navbar-nav > .open > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.profile__image--navbar {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.profile__image--settings {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.profile__image_thumb {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.user_list__user--invitation-pending {
|
||||
color: fade(@alert-danger-bg, 75%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn__new {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.navbar-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -25px !important; // center
|
||||
display: block;
|
||||
zoom: 0.9;
|
||||
}
|
||||
|
||||
.va-top {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
.navbar-collapse {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
a.dropdown--profile {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
line-height: 2.35;
|
||||
}
|
||||
|
||||
.navbar-inverse {
|
||||
background-color: @redash-gray;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-search {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
|
||||
.badge-light {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar .collapse.in {
|
||||
background: #fff;
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination > li > a, .pagination > li > span {
|
||||
border-radius: 3px !important;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
line-height: 31px;
|
||||
}
|
||||
|
||||
// Error state
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
margin-top: 25vh;
|
||||
padding: 35px;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
|
||||
.error-state__icon {
|
||||
.zmdi {
|
||||
font-size: 64px;
|
||||
color: @redash-gray;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
}
|
||||
|
||||
// Forms
|
||||
.form-control {
|
||||
border-radius: @redash-input-radius;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
border-color: @blue;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: @blue;
|
||||
}
|
||||
}
|
||||
|
||||
// Plotly
|
||||
text.slicetext {
|
||||
text-shadow: 1px 1px 5px #333;
|
||||
}
|
||||
|
||||
|
||||
// Responsive fixes
|
||||
@media (max-width: 767px) {
|
||||
.text-center-xs {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.query-page-wrapper {
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
left: 2%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
//Fix navbar collapse
|
||||
.navbar .collapse.in {
|
||||
border: none;
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--profile {
|
||||
.caret--nav:after {
|
||||
right: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--profile__username {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav__main li a {
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
text-align: left;
|
||||
float: none !important;
|
||||
}
|
||||
|
||||
.navbar-form {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 810px) {
|
||||
.menu-search {
|
||||
width: 175px;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
margin-left: 13px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1084px) {
|
||||
.dropdown--profile__username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Cross-browser fixes
|
||||
|
||||
// Firefox
|
||||
@-moz-document url-prefix() {
|
||||
.caret--nav::after {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.navbar .caret--nav::after {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.navbar .btn--create .caret--nav::after {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// IE10+
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.caret--nav::after {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.navbar .caret--nav::after {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.navbar .btn--create .caret--nav::after {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -19,8 +19,6 @@
|
||||
@import 'inc/ie-warning';
|
||||
@import 'inc/flex';
|
||||
|
||||
@import 'redash/redash-newstyle';
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
|
||||
22
client/app/components/AceEditorInput.jsx
Normal file
22
client/app/components/AceEditorInput.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import AceEditor from 'react-ace';
|
||||
|
||||
import './AceEditorInput.less';
|
||||
|
||||
function AceEditorInput(props, ref) {
|
||||
return (
|
||||
<div className="ace-editor-input">
|
||||
<AceEditor
|
||||
ref={ref}
|
||||
mode="sql"
|
||||
theme="textmate"
|
||||
height="100px"
|
||||
editorProps={{ $blockScrolling: Infinity }}
|
||||
showPrintMargin={false}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(AceEditorInput);
|
||||
11
client/app/components/AceEditorInput.less
Normal file
11
client/app/components/AceEditorInput.less
Normal file
@@ -0,0 +1,11 @@
|
||||
.ace-editor-input {
|
||||
// hide ghost cursor when not focused
|
||||
.ace_hidden-cursors {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// allow Ant Form feedback icon to hover scrollbar
|
||||
.ace_scrollbar {
|
||||
z-index: auto;
|
||||
}
|
||||
}
|
||||
83
client/app/components/BeaconConsent.jsx
Normal file
83
client/app/components/BeaconConsent.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Card from 'antd/lib/card';
|
||||
import Button from 'antd/lib/button';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import { HelpTrigger } from '@/components/HelpTrigger';
|
||||
import DynamicComponent from '@/components/DynamicComponent';
|
||||
import OrgSettings from '@/services/organizationSettings';
|
||||
|
||||
const Text = Typography.Text;
|
||||
|
||||
export function BeaconConsent() {
|
||||
const [hide, setHide] = useState(false);
|
||||
|
||||
if (!clientConfig.showBeaconConsentMessage || hide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hideConsentCard = () => {
|
||||
clientConfig.showBeaconConsentMessage = false;
|
||||
setHide(true);
|
||||
};
|
||||
|
||||
const confirmConsent = (confirm) => {
|
||||
let message = '🙏 Thank you.';
|
||||
|
||||
if (!confirm) {
|
||||
message = 'Settings Saved.';
|
||||
}
|
||||
|
||||
OrgSettings.save({ beacon_consent: confirm }, message)
|
||||
// .then(() => {
|
||||
// // const settings = get(response, 'settings');
|
||||
// // this.setState({ settings, formValues: { ...settings } });
|
||||
// })
|
||||
.finally(hideConsentCard);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicComponent name="BeaconConsent">
|
||||
<div className="m-t-10 tiled">
|
||||
<Card
|
||||
title={(
|
||||
<>
|
||||
Would you be ok with sharing anonymous usage data with the Redash team?{' '}
|
||||
<HelpTrigger type="USAGE_DATA_SHARING" />
|
||||
</>
|
||||
)}
|
||||
bordered={false}
|
||||
>
|
||||
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
||||
<div className="m-t-5">
|
||||
<ul>
|
||||
<li> Number of users, queries, dashboards, alerts, widgets and visualizations.</li>
|
||||
<li> Types of data sources, alert destinations and visualizations.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Text>All data is aggregated and will never include any sensitive or private data.</Text>
|
||||
<div className="m-t-5">
|
||||
<Button type="primary" className="m-r-5" onClick={() => confirmConsent(true)}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="default" onClick={() => confirmConsent(false)}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
<div className="m-t-15">
|
||||
<Text type="secondary">
|
||||
You can change this setting anytime from the <a href="settings/organization">Organization Settings</a> page.
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('beaconConsent', react2angular(BeaconConsent));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
80
client/app/components/CodeBlock.jsx
Normal file
80
client/app/components/CodeBlock.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from 'antd/lib/button';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import './CodeBlock.less';
|
||||
|
||||
export default class CodeBlock extends React.Component {
|
||||
static propTypes = {
|
||||
copyable: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
copyable: false,
|
||||
children: null,
|
||||
};
|
||||
|
||||
state = { copied: null };
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.ref = React.createRef();
|
||||
this.copyFeatureEnabled = props.copyable && document.queryCommandSupported('copy');
|
||||
this.resetCopyState = null;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.resetCopyState) {
|
||||
clearTimeout(this.resetCopyState);
|
||||
}
|
||||
}
|
||||
|
||||
copy = () => {
|
||||
// select text
|
||||
window.getSelection().selectAllChildren(this.ref.current);
|
||||
|
||||
// copy
|
||||
try {
|
||||
const success = document.execCommand('copy');
|
||||
if (!success) {
|
||||
throw new Error();
|
||||
}
|
||||
this.setState({ copied: 'Copied!' });
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
copied: 'Copy failed',
|
||||
});
|
||||
}
|
||||
|
||||
// reset selection
|
||||
window.getSelection().removeAllRanges();
|
||||
|
||||
// reset tooltip
|
||||
this.resetCopyState = setTimeout(() => this.setState({ copied: null }), 2000);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { copyable, children, ...props } = this.props;
|
||||
|
||||
const copyButton = (
|
||||
<Tooltip title={this.state.copied || 'Copy'}>
|
||||
<Button
|
||||
icon="copy"
|
||||
type="dashed"
|
||||
size="small"
|
||||
onClick={this.copy}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="code-block">
|
||||
<code {...props} ref={this.ref}>
|
||||
{children}
|
||||
</code>
|
||||
{this.copyFeatureEnabled && copyButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
23
client/app/components/CodeBlock.less
Normal file
23
client/app/components/CodeBlock.less
Normal file
@@ -0,0 +1,23 @@
|
||||
@import '~antd/lib/button/style/index';
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 2px;
|
||||
padding: 3px 27px 3px 3px;
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.@{btn-prefix-cls} {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
padding-left: 3px !important;
|
||||
padding-right: 3px !important;
|
||||
}
|
||||
}
|
||||
24
client/app/components/Collapse.jsx
Normal file
24
client/app/components/Collapse.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
import AntCollapse from 'antd/lib/collapse';
|
||||
|
||||
export default function Collapse({ collapsed, children, className, ...props }) {
|
||||
return (
|
||||
<AntCollapse {...props} activeKey={collapsed ? null : 'content'} className={cx(className, 'ant-collapse-headerless')}>
|
||||
<AntCollapse.Panel key="content" header="">{children}</AntCollapse.Panel>
|
||||
</AntCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
Collapse.propTypes = {
|
||||
collapsed: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Collapse.defaultProps = {
|
||||
collapsed: true,
|
||||
children: null,
|
||||
className: '',
|
||||
};
|
||||
12
client/app/components/ColorBox.jsx
Normal file
12
client/app/components/ColorBox.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
// ANGULAR_REMOVE_ME
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
|
||||
import './color-box.less';
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('colorBox', react2angular(ColorPicker.Swatch));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
93
client/app/components/ColorPicker/Input.jsx
Normal file
93
client/app/components/ColorPicker/Input.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { isNil, isArray, chunk, map, filter, toPairs } from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import TextInput from 'antd/lib/input';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import Swatch from './Swatch';
|
||||
|
||||
import './input.less';
|
||||
|
||||
function preparePresets(presetColors, presetColumns) {
|
||||
presetColors = isArray(presetColors) ? map(presetColors, v => [null, v]) : toPairs(presetColors);
|
||||
presetColors = map(presetColors, ([title, value]) => {
|
||||
if (isNil(value)) {
|
||||
return [title, null];
|
||||
}
|
||||
value = tinycolor(value);
|
||||
if (value.isValid()) {
|
||||
return [title, '#' + value.toHex().toUpperCase()];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return chunk(filter(presetColors), presetColumns);
|
||||
}
|
||||
|
||||
function validateColor(value, callback, prefix = '#') {
|
||||
if (isNil(value)) {
|
||||
callback(null);
|
||||
}
|
||||
value = tinycolor(value);
|
||||
if (value.isValid()) {
|
||||
callback(prefix + value.toHex().toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
export default function Input({ color, presetColors, presetColumns, onChange, onPressEnter }) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const presets = preparePresets(presetColors, presetColumns);
|
||||
|
||||
function handleInputChange(value) {
|
||||
setInputValue(value);
|
||||
validateColor(value, onChange);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInputFocused) {
|
||||
validateColor(color, setInputValue, '');
|
||||
}
|
||||
}, [color, isInputFocused]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{map(presets, (group, index) => (
|
||||
<div className="color-picker-input-swatches" key={`preset-row-${index}`}>
|
||||
{map(group, ([title, value]) => (
|
||||
<Swatch key={value} color={value} title={title} size={30} onClick={() => validateColor(value, onChange)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="color-picker-input">
|
||||
<TextInput
|
||||
addonBefore={<Typography.Text type="secondary">#</Typography.Text>}
|
||||
value={inputValue}
|
||||
onChange={e => handleInputChange(e.target.value)}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onPressEnter={onPressEnter}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Input.propTypes = {
|
||||
color: PropTypes.string,
|
||||
presetColors: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips)
|
||||
PropTypes.objectOf(PropTypes.string), // color name => color value
|
||||
]),
|
||||
presetColumns: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
onPressEnter: PropTypes.func,
|
||||
};
|
||||
|
||||
Input.defaultProps = {
|
||||
color: '#FFFFFF',
|
||||
presetColors: null,
|
||||
presetColumns: 8,
|
||||
onChange: () => {},
|
||||
onPressEnter: () => {},
|
||||
};
|
||||
37
client/app/components/ColorPicker/Swatch.jsx
Normal file
37
client/app/components/ColorPicker/Swatch.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { isString } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
|
||||
import './swatch.less';
|
||||
|
||||
export default function Swatch({ className, color, title, size, ...props }) {
|
||||
const result = (
|
||||
<span
|
||||
className={`color-swatch ${className}`}
|
||||
style={{ backgroundColor: color, width: size }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isString(title) && (title !== '')) {
|
||||
return (
|
||||
<Tooltip title={title} mouseEnterDelay={0} mouseLeaveDelay={0}>{result}</Tooltip>
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Swatch.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
};
|
||||
|
||||
Swatch.defaultProps = {
|
||||
className: '',
|
||||
title: null,
|
||||
color: 'transparent',
|
||||
size: 12,
|
||||
};
|
||||
128
client/app/components/ColorPicker/index.jsx
Normal file
128
client/app/components/ColorPicker/index.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { toString } from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import Popover from 'antd/lib/popover';
|
||||
import Card from 'antd/lib/card';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import Icon from 'antd/lib/icon';
|
||||
|
||||
import ColorInput from './Input';
|
||||
import Swatch from './Swatch';
|
||||
|
||||
import './index.less';
|
||||
|
||||
function validateColor(value, fallback = null) {
|
||||
value = tinycolor(value);
|
||||
return value.isValid() ? '#' + value.toHex().toUpperCase() : fallback;
|
||||
}
|
||||
|
||||
export default function ColorPicker({
|
||||
color, placement, presetColors, presetColumns, triggerSize, interactive, children, onChange,
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [currentColor, setCurrentColor] = useState('');
|
||||
|
||||
function handleApply() {
|
||||
setVisible(false);
|
||||
if (!interactive) {
|
||||
onChange(currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
if (!interactive) {
|
||||
actions.push((
|
||||
<Tooltip key="cancel" title="Cancel">
|
||||
<Icon type="close" onClick={handleCancel} />
|
||||
</Tooltip>
|
||||
));
|
||||
actions.push((
|
||||
<Tooltip key="apply" title="Apply">
|
||||
<Icon type="check" onClick={handleApply} />
|
||||
</Tooltip>
|
||||
));
|
||||
}
|
||||
|
||||
function handleInputChange(newColor) {
|
||||
setCurrentColor(newColor);
|
||||
if (interactive) {
|
||||
onChange(newColor);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentColor(validateColor(color));
|
||||
}
|
||||
}, [color, visible]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
overlayClassName={`color-picker ${interactive ? 'color-picker-interactive' : 'color-picker-with-actions'}`}
|
||||
overlayStyle={{ '--color-picker-selected-color': currentColor }}
|
||||
content={(
|
||||
<Card
|
||||
className="color-picker-panel"
|
||||
bordered={false}
|
||||
title={toString(currentColor).toUpperCase()}
|
||||
headStyle={{
|
||||
backgroundColor: currentColor,
|
||||
color: tinycolor(currentColor).isLight() ? '#000000' : '#ffffff',
|
||||
}}
|
||||
actions={actions}
|
||||
>
|
||||
<ColorInput
|
||||
color={currentColor}
|
||||
presetColors={presetColors}
|
||||
presetColumns={presetColumns}
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleApply}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
trigger="click"
|
||||
placement={placement}
|
||||
visible={visible}
|
||||
onVisibleChange={setVisible}
|
||||
>
|
||||
{children || (<Swatch className="color-picker-trigger" color={validateColor(color)} size={triggerSize} />)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
ColorPicker.propTypes = {
|
||||
color: PropTypes.string,
|
||||
placement: PropTypes.oneOf([
|
||||
'top', 'left', 'right', 'bottom',
|
||||
'topLeft', 'topRight', 'bottomLeft', 'bottomRight',
|
||||
'leftTop', 'leftBottom', 'rightTop', 'rightBottom',
|
||||
]),
|
||||
presetColors: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips)
|
||||
PropTypes.objectOf(PropTypes.string), // color name => color value
|
||||
]),
|
||||
presetColumns: PropTypes.number,
|
||||
triggerSize: PropTypes.number,
|
||||
interactive: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
ColorPicker.defaultProps = {
|
||||
color: '#FFFFFF',
|
||||
placement: 'top',
|
||||
presetColors: null,
|
||||
presetColumns: 8,
|
||||
triggerSize: 30,
|
||||
interactive: false,
|
||||
children: null,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
ColorPicker.Input = ColorInput;
|
||||
ColorPicker.Swatch = Swatch;
|
||||
40
client/app/components/ColorPicker/index.less
Normal file
40
client/app/components/ColorPicker/index.less
Normal file
@@ -0,0 +1,40 @@
|
||||
.color-picker {
|
||||
&.color-picker-with-actions {
|
||||
&.ant-popover-placement-top,
|
||||
&.ant-popover-placement-topLeft,
|
||||
&.ant-popover-placement-topRight,
|
||||
&.ant-popover-placement-leftBottom,
|
||||
&.ant-popover-placement-rightBottom {
|
||||
> .ant-popover-content > .ant-popover-arrow {
|
||||
border-color: #fafafa; // same as card actions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-popover-placement-bottom,
|
||||
&.ant-popover-placement-bottomLeft,
|
||||
&.ant-popover-placement-bottomRight,
|
||||
&.ant-popover-placement-leftTop,
|
||||
&.ant-popover-placement-rightTop {
|
||||
> .ant-popover-content > .ant-popover-arrow {
|
||||
border-color: var(--color-picker-selected-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
text-align: center;
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
19
client/app/components/ColorPicker/input.less
Normal file
19
client/app/components/ColorPicker/input.less
Normal file
@@ -0,0 +1,19 @@
|
||||
.color-picker-input-swatches {
|
||||
margin: 0 0 10px 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
.color-swatch {
|
||||
cursor: pointer;
|
||||
margin: 0 10px 0 0;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
30
client/app/components/ColorPicker/swatch.less
Normal file
30
client/app/components/ColorPicker/swatch.less
Normal file
@@ -0,0 +1,30 @@
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
width: 12px;
|
||||
|
||||
@cell-size: 12px;
|
||||
@cell-color: rgba(0, 0, 0, 0.1);
|
||||
|
||||
background-color: transparent;
|
||||
background-image:
|
||||
linear-gradient(45deg, @cell-color 25%, transparent 25%),
|
||||
linear-gradient(-45deg, @cell-color 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, @cell-color 75%),
|
||||
linear-gradient(-45deg, transparent 75%, @cell-color 75%);
|
||||
background-size: @cell-size @cell-size;
|
||||
background-position: 0 0, 0 @cell-size/2, @cell-size/2 -@cell-size/2, -@cell-size/2 0px;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: ~"calc(100% - 2px)";
|
||||
background-color: inherit;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
191
client/app/components/CreateSourceDialog.jsx
Normal file
191
client/app/components/CreateSourceDialog.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEmpty, toUpper, includes } from 'lodash';
|
||||
import Button from 'antd/lib/button';
|
||||
import List from 'antd/lib/list';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Input from 'antd/lib/input';
|
||||
import Steps from 'antd/lib/steps';
|
||||
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
|
||||
import { PreviewCard } from '@/components/PreviewCard';
|
||||
import EmptyState from '@/components/items-list/components/EmptyState';
|
||||
import DynamicForm from '@/components/dynamic-form/DynamicForm';
|
||||
import helper from '@/components/dynamic-form/dynamicFormHelper';
|
||||
import { HelpTrigger, TYPES as HELP_TRIGGER_TYPES } from '@/components/HelpTrigger';
|
||||
|
||||
const { Step } = Steps;
|
||||
const { Search } = Input;
|
||||
|
||||
const StepEnum = {
|
||||
SELECT_TYPE: 0,
|
||||
CONFIGURE_IT: 1,
|
||||
DONE: 2,
|
||||
};
|
||||
|
||||
class CreateSourceDialog extends React.Component {
|
||||
static propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
types: PropTypes.arrayOf(PropTypes.object),
|
||||
sourceType: PropTypes.string.isRequired,
|
||||
imageFolder: PropTypes.string.isRequired,
|
||||
helpTriggerPrefix: PropTypes.string,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
types: [],
|
||||
helpTriggerPrefix: null,
|
||||
};
|
||||
|
||||
state = {
|
||||
searchText: '',
|
||||
selectedType: null,
|
||||
savingSource: false,
|
||||
currentStep: StepEnum.SELECT_TYPE,
|
||||
};
|
||||
|
||||
selectType = (selectedType) => {
|
||||
this.setState({ selectedType, currentStep: StepEnum.CONFIGURE_IT });
|
||||
};
|
||||
|
||||
resetType = () => {
|
||||
if (this.state.currentStep === StepEnum.CONFIGURE_IT) {
|
||||
this.setState({ searchText: '', selectedType: null, currentStep: StepEnum.SELECT_TYPE });
|
||||
}
|
||||
};
|
||||
|
||||
createSource = (values, successCallback, errorCallback) => {
|
||||
const { selectedType, savingSource } = this.state;
|
||||
if (!savingSource) {
|
||||
this.setState({ savingSource: true, currentStep: StepEnum.DONE });
|
||||
this.props.onCreate(selectedType, values).then((data) => {
|
||||
successCallback('Saved.');
|
||||
this.props.dialog.close({ success: true, data });
|
||||
}).catch((error) => {
|
||||
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
|
||||
errorCallback(error.message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
renderTypeSelector() {
|
||||
const { types } = this.props;
|
||||
const { searchText } = this.state;
|
||||
const filteredTypes = types.filter(type => isEmpty(searchText) ||
|
||||
includes(type.name.toLowerCase(), searchText.toLowerCase()));
|
||||
return (
|
||||
<div className="m-t-10">
|
||||
<Search
|
||||
placeholder="Search..."
|
||||
onChange={e => this.setState({ searchText: e.target.value })}
|
||||
autoFocus
|
||||
data-test="SearchSource"
|
||||
/>
|
||||
<div className="scrollbox p-5 m-t-10" style={{ minHeight: '30vh', maxHeight: '40vh' }}>
|
||||
{isEmpty(filteredTypes) ? (<EmptyState className="" />) : (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={filteredTypes}
|
||||
renderItem={item => this.renderItem(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
const { imageFolder, helpTriggerPrefix } = this.props;
|
||||
const { selectedType } = this.state;
|
||||
const fields = helper.getFields(selectedType);
|
||||
const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`;
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<img
|
||||
className="p-5"
|
||||
src={`${imageFolder}/${selectedType.type}.png`}
|
||||
alt={selectedType.name}
|
||||
width="48"
|
||||
/>
|
||||
<h4 className="m-0">{selectedType.name}</h4>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{HELP_TRIGGER_TYPES[helpTriggerType] && (
|
||||
<HelpTrigger className="f-13" type={helpTriggerType}>
|
||||
Setup Instructions <i className="fa fa-question-circle" />
|
||||
</HelpTrigger>
|
||||
)}
|
||||
</div>
|
||||
<DynamicForm
|
||||
id="sourceForm"
|
||||
fields={fields}
|
||||
onSubmit={this.createSource}
|
||||
feedbackIcons
|
||||
hideSubmitButton
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderItem(item) {
|
||||
const { imageFolder } = this.props;
|
||||
return (
|
||||
<List.Item
|
||||
className="p-l-10 p-r-10 clickable"
|
||||
onClick={() => this.selectType(item)}
|
||||
>
|
||||
<PreviewCard title={item.name} imageUrl={`${imageFolder}/${item.type}.png`} roundedImage={false}>
|
||||
<i className="fa fa-angle-double-right" />
|
||||
</PreviewCard>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentStep, savingSource } = this.state;
|
||||
const { dialog, sourceType } = this.props;
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title={`Create a New ${sourceType}`}
|
||||
footer={(currentStep === StepEnum.SELECT_TYPE) ? [
|
||||
(<Button key="cancel" onClick={() => dialog.dismiss()}>Cancel</Button>),
|
||||
(<Button key="submit" type="primary" disabled>Create</Button>),
|
||||
] : [
|
||||
(<Button key="previous" onClick={this.resetType}>Previous</Button>),
|
||||
(
|
||||
<Button
|
||||
key="submit"
|
||||
htmlType="submit"
|
||||
form="sourceForm"
|
||||
type="primary"
|
||||
loading={savingSource}
|
||||
data-test="CreateSourceButton"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<div data-test="CreateSourceDialog">
|
||||
<Steps className="hidden-xs m-b-10" size="small" current={currentStep} progressDot>
|
||||
{currentStep === StepEnum.CONFIGURE_IT ? (
|
||||
<Step
|
||||
title={<a>Type Selection</a>}
|
||||
className="clickable"
|
||||
onClick={this.resetType}
|
||||
/>
|
||||
) : (<Step title="Type Selection" />)}
|
||||
<Step title="Configuration" />
|
||||
<Step title="Done" />
|
||||
</Steps>
|
||||
{currentStep === StepEnum.SELECT_TYPE && this.renderTypeSelector()}
|
||||
{currentStep !== StepEnum.SELECT_TYPE && this.renderForm()}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default wrapDialog(CreateSourceDialog);
|
||||
@@ -1,45 +1,49 @@
|
||||
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({
|
||||
const DateInput = React.forwardRef(({
|
||||
defaultValue,
|
||||
value,
|
||||
onSelect,
|
||||
className,
|
||||
}) {
|
||||
...props
|
||||
}, ref) => {
|
||||
const format = clientConfig.dateFormat || 'YYYY-MM-DD';
|
||||
const additionalAttributes = {};
|
||||
if (value && value.isValid()) {
|
||||
additionalAttributes.defaultValue = value;
|
||||
if (defaultValue && defaultValue.isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (value && value.isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<DatePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
placeholder="Select Date"
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DateInput.propTypes = {
|
||||
defaultValue: Moment,
|
||||
value: Moment,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DateInput.defaultProps = {
|
||||
value: null,
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dateInput', react2angular(DateInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default DateInput;
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
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({
|
||||
const DateRangeInput = React.forwardRef(({
|
||||
defaultValue,
|
||||
value,
|
||||
onSelect,
|
||||
className,
|
||||
}) {
|
||||
...props
|
||||
}, ref) => {
|
||||
const format = clientConfig.dateFormat || 'YYYY-MM-DD';
|
||||
const additionalAttributes = {};
|
||||
if (isArray(value) && value[0].isValid() && value[1].isValid()) {
|
||||
additionalAttributes.defaultValue = value;
|
||||
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<RangePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DateRangeInput.propTypes = {
|
||||
defaultValue: PropTypes.arrayOf(Moment),
|
||||
value: PropTypes.arrayOf(Moment),
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DateRangeInput.defaultProps = {
|
||||
value: null,
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dateRangeInput', react2angular(DateRangeInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default DateRangeInput;
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
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({
|
||||
const DateTimeInput = React.forwardRef(({
|
||||
defaultValue,
|
||||
value,
|
||||
withSeconds,
|
||||
onSelect,
|
||||
className,
|
||||
}) {
|
||||
...props
|
||||
}, ref) => {
|
||||
const format = (clientConfig.dateFormat || 'YYYY-MM-DD') +
|
||||
(withSeconds ? ' HH:mm:ss' : ' HH:mm');
|
||||
const additionalAttributes = {};
|
||||
if (value && value.isValid()) {
|
||||
additionalAttributes.defaultValue = value;
|
||||
if (defaultValue && defaultValue.isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (value && value.isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<DatePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
showTime
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
placeholder="Select Date and Time"
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DateTimeInput.propTypes = {
|
||||
defaultValue: Moment,
|
||||
value: Moment,
|
||||
withSeconds: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
@@ -37,14 +44,11 @@ DateTimeInput.propTypes = {
|
||||
};
|
||||
|
||||
DateTimeInput.defaultProps = {
|
||||
value: null,
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
withSeconds: false,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dateTimeInput', react2angular(DateTimeInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default DateTimeInput;
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
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 DateTimeRangeInput({
|
||||
const DateTimeRangeInput = React.forwardRef(({
|
||||
defaultValue,
|
||||
value,
|
||||
withSeconds,
|
||||
onSelect,
|
||||
className,
|
||||
}) {
|
||||
...props
|
||||
}, ref) => {
|
||||
const format = (clientConfig.dateFormat || 'YYYY-MM-DD') +
|
||||
(withSeconds ? ' HH:mm:ss' : ' HH:mm');
|
||||
const additionalAttributes = {};
|
||||
if (isArray(value) && value[0].isValid() && value[1].isValid()) {
|
||||
additionalAttributes.defaultValue = value;
|
||||
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<RangePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
showTime
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DateTimeRangeInput.propTypes = {
|
||||
defaultValue: PropTypes.arrayOf(Moment),
|
||||
value: PropTypes.arrayOf(Moment),
|
||||
withSeconds: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
@@ -39,14 +46,11 @@ DateTimeRangeInput.propTypes = {
|
||||
};
|
||||
|
||||
DateTimeRangeInput.defaultProps = {
|
||||
value: null,
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
withSeconds: false,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dateTimeRangeInput', react2angular(DateTimeRangeInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default DateTimeRangeInput;
|
||||
|
||||
@@ -43,7 +43,7 @@ export default class DynamicComponent extends React.Component {
|
||||
const { name, children, ...props } = this.props;
|
||||
const RealComponent = componentsRegistry.get(name);
|
||||
if (!RealComponent) {
|
||||
return null;
|
||||
return children;
|
||||
}
|
||||
return <RealComponent {...props}>{children}</RealComponent>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
import { includes, startsWith, words, capitalize, clone, isNull } from 'lodash';
|
||||
import { includes, words, capitalize, clone, isNull } from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Checkbox from 'antd/lib/checkbox';
|
||||
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';
|
||||
@@ -20,14 +20,17 @@ 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 joinExampleList(multiValuesOptions) {
|
||||
const { prefix, suffix } = multiValuesOptions;
|
||||
return ['value1', 'value2', 'value3']
|
||||
.map(value => `${prefix}${value}${suffix}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
||||
let helpText = '';
|
||||
let validateStatus = '';
|
||||
@@ -131,7 +134,7 @@ function EditParameterSettingsDialog(props) {
|
||||
footer={[(
|
||||
<Button key="cancel" onClick={props.dialog.dismiss}>Cancel</Button>
|
||||
), (
|
||||
<Button key="submit" htmlType="submit" disabled={!isFulfilled()} type="primary" form="paramForm">
|
||||
<Button key="submit" htmlType="submit" disabled={!isFulfilled()} type="primary" form="paramForm" data-test="SaveParameterSettings">
|
||||
{isNew ? 'Add Parameter' : 'OK'}
|
||||
</Button>
|
||||
)]}
|
||||
@@ -150,40 +153,31 @@ function EditParameterSettingsDialog(props) {
|
||||
<Input
|
||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
||||
data-test="ParameterTitleInput"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Type" {...formItemProps}>
|
||||
<Select value={param.type} onChange={type => setParam({ ...param, type })}>
|
||||
<Option value="text">Text</Option>
|
||||
<Option value="number">Number</Option>
|
||||
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect">
|
||||
<Option value="text" data-test="TextParameterTypeOption">Text</Option>
|
||||
<Option value="number" data-test="NumberParameterTypeOption">Number</Option>
|
||||
<Option value="enum">Dropdown List</Option>
|
||||
<Option value="query">Query Based Dropdown List</Option>
|
||||
<Option disabled key="dv1">
|
||||
<Divider className="select-option-divider" />
|
||||
</Option>
|
||||
<Option value="date">Date</Option>
|
||||
<Option value="datetime-local">Date and Time</Option>
|
||||
<Option value="date" data-test="DateParameterTypeOption">Date</Option>
|
||||
<Option value="datetime-local" data-test="DateTimeParameterTypeOption">Date and Time</Option>
|
||||
<Option value="datetime-with-seconds">Date and Time (with seconds)</Option>
|
||||
<Option disabled key="dv2">
|
||||
<Divider className="select-option-divider" />
|
||||
</Option>
|
||||
<Option value="date-range">Date Range</Option>
|
||||
<Option value="date-range" data-test="DateRangeParameterTypeOption">Date Range</Option>
|
||||
<Option value="datetime-range">Date and Time Range</Option>
|
||||
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{isTypeDate(param.type) && (
|
||||
<Form.Item label=" " colon={false} {...formItemProps}>
|
||||
<Checkbox
|
||||
defaultChecked={param.useCurrentDateTime}
|
||||
onChange={e => setParam({ ...param, useCurrentDateTime: e.target.checked })}
|
||||
>
|
||||
Default to Today/Now if no other value is set
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{param.type === 'enum' && (
|
||||
<Form.Item label="Values" help="Dropdown list values (newline delimeted)" {...formItemProps}>
|
||||
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={param.enumOptions}
|
||||
@@ -200,6 +194,48 @@ function EditParameterSettingsDialog(props) {
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{(param.type === 'enum' || param.type === 'query') && (
|
||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||
<Checkbox
|
||||
defaultChecked={!!param.multiValuesOptions}
|
||||
onChange={e => setParam({ ...param,
|
||||
multiValuesOptions: e.target.checked ? {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
separator: ',',
|
||||
} : null })}
|
||||
data-test="AllowMultipleValuesCheckbox"
|
||||
>
|
||||
Allow multiple values
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{(param.type === 'enum' || param.type === 'query') && param.multiValuesOptions && (
|
||||
<Form.Item
|
||||
label="Quotation"
|
||||
help={(
|
||||
<React.Fragment>
|
||||
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{...formItemProps}
|
||||
>
|
||||
<Select
|
||||
value={param.multiValuesOptions.prefix}
|
||||
onChange={quoteOption => setParam({ ...param,
|
||||
multiValuesOptions: {
|
||||
...param.multiValuesOptions,
|
||||
prefix: quoteOption,
|
||||
suffix: quoteOption,
|
||||
} })}
|
||||
data-test="QuotationSelect"
|
||||
>
|
||||
<Option value="">None (default)</Option>
|
||||
<Option value="'">Single Quotation Mark</Option>
|
||||
<Option value={'"'} data-test="DoubleQuotationMarkOption">Double Quotation Mark</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Dropdown from 'antd/lib/dropdown';
|
||||
import Menu from 'antd/lib/menu';
|
||||
import Button from 'antd/lib/button';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
import QueryResultsLink from './QueryResultsLink';
|
||||
|
||||
|
||||
export function QueryControlDropdown(props) {
|
||||
const menu = (
|
||||
<Menu>
|
||||
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
|
||||
<Menu.Item>
|
||||
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
|
||||
<Icon type="plus-circle" theme="filled" /> Add to Dashboard
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!props.query.isNew() && (
|
||||
<Menu.Item>
|
||||
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
||||
<Icon type="share-alt" /> Embed Elsewhere
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item>
|
||||
<QueryResultsLink
|
||||
disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()}
|
||||
query={props.query}
|
||||
queryResult={props.queryResult}
|
||||
embed={props.embed}
|
||||
apiKey={props.apiKey}
|
||||
>
|
||||
<Icon type="file" /> Download as CSV File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<QueryResultsLink
|
||||
fileType="xlsx"
|
||||
disabled={props.queryExecuting || !props.queryResult.getData || !props.queryResult.getData()}
|
||||
query={props.query}
|
||||
queryResult={props.queryResult}
|
||||
embed={props.embed}
|
||||
apiKey={props.apiKey}
|
||||
>
|
||||
<Icon type="file-excel" /> Download as Excel File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
overlay={menu}
|
||||
overlayClassName="query-control-dropdown-overlay"
|
||||
>
|
||||
<Button data-test="QueryControlDropdownButton">
|
||||
<Icon type="ellipsis" rotate={90} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
QueryControlDropdown.propTypes = {
|
||||
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
queryExecuting: PropTypes.bool.isRequired,
|
||||
showEmbedDialog: PropTypes.func.isRequired,
|
||||
embed: PropTypes.bool,
|
||||
apiKey: PropTypes.string,
|
||||
selectedTab: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
openAddToDashboardForm: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
QueryControlDropdown.defaultProps = {
|
||||
queryResult: {},
|
||||
embed: false,
|
||||
apiKey: '',
|
||||
selectedTab: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('queryControlDropdown', react2angular(QueryControlDropdown));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
|
||||
export default function QueryResultsLink(props) {
|
||||
let href = '';
|
||||
|
||||
const { query, queryResult, fileType } = props;
|
||||
const resultId = queryResult.getId && queryResult.getId();
|
||||
const resultData = queryResult.getData && queryResult.getData();
|
||||
|
||||
if (resultId && resultData && query.name) {
|
||||
if (query.id) {
|
||||
href = `api/queries/${query.id}/results/${resultId}.${fileType}${
|
||||
props.embed ? `?api_key=${props.apiKey}` : ''
|
||||
}`;
|
||||
} else {
|
||||
href = `api/query_results/${resultId}.${fileType}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a target="_self" disabled={props.disabled} href={href}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
QueryResultsLink.propTypes = {
|
||||
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
fileType: PropTypes.string,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
embed: PropTypes.bool,
|
||||
apiKey: PropTypes.string,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
QueryResultsLink.defaultProps = {
|
||||
queryResult: {},
|
||||
fileType: 'csv',
|
||||
embed: false,
|
||||
apiKey: '',
|
||||
};
|
||||
39
client/app/components/EditVisualizationButton/index.jsx
Normal file
39
client/app/components/EditVisualizationButton/index.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from 'antd/lib/button';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
|
||||
export function EditVisualizationButton(props) {
|
||||
return (
|
||||
<Button
|
||||
data-test="EditVisualization"
|
||||
className="edit-visualization"
|
||||
onClick={() => props.openVisualizationEditor(props.selectedTab)}
|
||||
>
|
||||
<Icon type="form" />
|
||||
<span className="hidden-xs hidden-s hidden-m">
|
||||
Edit Visualization
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
EditVisualizationButton.propTypes = {
|
||||
openVisualizationEditor: PropTypes.func.isRequired,
|
||||
selectedTab: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
};
|
||||
|
||||
EditVisualizationButton.defaultProps = {
|
||||
selectedTab: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editVisualizationButton', react2angular(EditVisualizationButton));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -37,7 +37,6 @@ export class FavoritesControl extends React.Component {
|
||||
const title = item.is_favorite ? 'Remove from favorites' : 'Add to favorites';
|
||||
return (
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
title={title}
|
||||
className="btn-favourite"
|
||||
onClick={event => this.toggleItem(event, item, onChange)}
|
||||
|
||||
155
client/app/components/Filters.jsx
Normal file
155
client/app/components/Filters.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { isArray, indexOf, get, map, includes, every, some, toNumber, toLower } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Select from 'antd/lib/select';
|
||||
import { formatDateTime, formatDate } from '@/filters/datetime';
|
||||
|
||||
const ALL_VALUES = '###Redash::Filters::SelectAll###';
|
||||
const NONE_VALUES = '###Redash::Filters::Clear###';
|
||||
|
||||
export const FilterType = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
friendlyName: PropTypes.string.isRequired,
|
||||
multiple: PropTypes.bool,
|
||||
current: PropTypes.oneOfType([
|
||||
PropTypes.any,
|
||||
PropTypes.arrayOf(PropTypes.any),
|
||||
]),
|
||||
values: PropTypes.arrayOf(PropTypes.any).isRequired,
|
||||
});
|
||||
|
||||
export const FiltersType = PropTypes.arrayOf(FilterType);
|
||||
|
||||
function createFilterChangeHandler(filters, onChange) {
|
||||
return (filter, values) => {
|
||||
if (isArray(values)) {
|
||||
values = map(values, value => filter.values[toNumber(value.key)] || value.key);
|
||||
} else {
|
||||
const _values = filter.values[toNumber(values.key)];
|
||||
values = _values !== undefined ? _values : values.key;
|
||||
}
|
||||
|
||||
if (filter.multiple && includes(values, ALL_VALUES)) {
|
||||
values = [...filter.values];
|
||||
}
|
||||
if (filter.multiple && includes(values, NONE_VALUES)) {
|
||||
values = [];
|
||||
}
|
||||
filters = map(filters, f => (f.name === filter.name ? { ...filter, current: values } : f));
|
||||
onChange(filters);
|
||||
};
|
||||
}
|
||||
|
||||
export function filterData(rows, filters = []) {
|
||||
if (!isArray(rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let result = rows;
|
||||
|
||||
if (isArray(filters) && (filters.length > 0)) {
|
||||
// "every" field's value should match "some" of corresponding filter's values
|
||||
result = result.filter(row => every(
|
||||
filters,
|
||||
(filter) => {
|
||||
const rowValue = row[filter.name];
|
||||
const filterValues = isArray(filter.current) ? filter.current : [filter.current];
|
||||
return some(filterValues, (filterValue) => {
|
||||
if (moment.isMoment(rowValue)) {
|
||||
return rowValue.isSame(filterValue);
|
||||
}
|
||||
// We compare with either the value or the String representation of the value,
|
||||
// because Select2 casts true/false to "true"/"false".
|
||||
return (filterValue === rowValue) || (String(rowValue) === filterValue);
|
||||
});
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatValue(value, columnType) {
|
||||
if (moment.isMoment(value)) {
|
||||
if (columnType === 'date') {
|
||||
return formatDate(value);
|
||||
}
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function Filters({ filters, onChange }) {
|
||||
if (filters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
onChange = createFilterChangeHandler(filters, onChange);
|
||||
|
||||
return (
|
||||
<div className="filters-wrapper">
|
||||
<div className="container bg-white">
|
||||
<div className="row">
|
||||
{map(filters, (filter) => {
|
||||
const options = map(filter.values, (value, index) => (
|
||||
<Select.Option key={index}>{formatValue(value, get(filter, 'column.type'))}</Select.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
<div key={filter.name} className="col-sm-6 p-l-0 filter-container">
|
||||
<label>{filter.friendlyName}</label>
|
||||
{(options.length === 0) && (
|
||||
<Select className="w-100" disabled value="No values" />
|
||||
)}
|
||||
{(options.length > 0) && (
|
||||
<Select
|
||||
labelInValue
|
||||
className="w-100"
|
||||
mode={filter.multiple ? 'multiple' : 'default'}
|
||||
value={isArray(filter.current) ?
|
||||
map(filter.current,
|
||||
value => ({ key: `${indexOf(filter.values, value)}`, label: formatValue(value) })) :
|
||||
({ key: `${indexOf(filter.values, filter.current)}`, label: formatValue(filter.current) })}
|
||||
allowClear={filter.multiple}
|
||||
filterOption={(searchText, option) => includes(toLower(option.props.children), toLower(searchText))}
|
||||
showSearch
|
||||
onChange={values => onChange(filter, values)}
|
||||
>
|
||||
{!filter.multiple && options}
|
||||
{filter.multiple && [
|
||||
<Select.Option key={NONE_VALUES}><i className="fa fa-square-o m-r-5" />Clear</Select.Option>,
|
||||
<Select.Option key={ALL_VALUES}><i className="fa fa-check-square-o m-r-5" />Select All</Select.Option>,
|
||||
<Select.OptGroup key="Values" title="Values">{options}</Select.OptGroup>,
|
||||
]}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Filters.propTypes = {
|
||||
filters: FiltersType.isRequired,
|
||||
onChange: PropTypes.func, // (name, value) => void
|
||||
};
|
||||
|
||||
Filters.defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('filters', react2angular(Filters));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
export function Footer() {
|
||||
const separator = ' \u2022 ';
|
||||
|
||||
return (
|
||||
<div id="footer">
|
||||
<a href="https://redash.io">Redash</a>
|
||||
{separator}
|
||||
<a href="https://redash.io/help/">Documentation</a>
|
||||
{separator}
|
||||
<a href="https://github.com/getredash/redash">Contribute</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('footer', react2angular(Footer));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -4,6 +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 { BigMessage } from '@/components/BigMessage';
|
||||
import DynamicComponent from '@/components/DynamicComponent';
|
||||
|
||||
@@ -12,7 +13,9 @@ import './HelpTrigger.less';
|
||||
const DOMAIN = 'https://redash.io';
|
||||
const HELP_PATH = '/help';
|
||||
const IFRAME_TIMEOUT = 20000;
|
||||
const TYPES = {
|
||||
const IFRAME_URL_UPDATE_MESSAGE = 'iframe_url';
|
||||
|
||||
export const TYPES = {
|
||||
HOME: [
|
||||
'',
|
||||
'Help',
|
||||
@@ -25,21 +28,63 @@ const TYPES = {
|
||||
'/user-guide/dashboards/sharing-dashboards',
|
||||
'Guide: Sharing and Embedding Dashboards',
|
||||
],
|
||||
AUTHENTICATION_OPTIONS: [
|
||||
'/user-guide/users/authentication-options',
|
||||
'Guide: Authentication Options',
|
||||
],
|
||||
USAGE_DATA_SHARING: [
|
||||
'/open-source/admin-guide/usage-data',
|
||||
'Help: Anonymous Usage Data Sharing',
|
||||
],
|
||||
DS_ATHENA: [
|
||||
'/data-sources/amazon-athena-setup',
|
||||
'Guide: Help Setting up Amazon Athena',
|
||||
],
|
||||
DS_BIGQUERY: [
|
||||
'/data-sources/bigquery-setup',
|
||||
'Guide: Help Setting up BigQuery',
|
||||
],
|
||||
DS_URL: [
|
||||
'/data-sources/querying-urls',
|
||||
'Guide: Help Setting up URL',
|
||||
],
|
||||
DS_MONGODB: [
|
||||
'/data-sources/mongodb-setup',
|
||||
'Guide: Help Setting up MongoDB',
|
||||
],
|
||||
DS_GOOGLE_SPREADSHEETS: [
|
||||
'/data-sources/querying-a-google-spreadsheet',
|
||||
'Guide: Help Setting up Google Spreadsheets',
|
||||
],
|
||||
DS_GOOGLE_ANALYTICS: [
|
||||
'/data-sources/google-analytics-setup',
|
||||
'Guide: Help Setting up Google Analytics',
|
||||
],
|
||||
DS_AXIBASETSD: [
|
||||
'/data-sources/axibase-time-series-database',
|
||||
'Guide: Help Setting up Axibase Time Series',
|
||||
],
|
||||
DS_RESULTS: [
|
||||
'/user-guide/querying/query-results-data-source',
|
||||
'Guide: Help Setting up Query Results',
|
||||
],
|
||||
};
|
||||
|
||||
export class HelpTrigger extends React.Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.oneOf(Object.keys(TYPES)).isRequired,
|
||||
className: PropTypes.string,
|
||||
}
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
className: null,
|
||||
children: <i className="fa fa-question-circle" />,
|
||||
};
|
||||
|
||||
iframeRef = null
|
||||
iframeRef = null;
|
||||
|
||||
iframeLoadingTimeout = null
|
||||
iframeLoadingTimeout = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -50,9 +95,15 @@ export class HelpTrigger extends React.Component {
|
||||
visible: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
currentUrl: null,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('message', this.onPostMessageReceived, DOMAIN);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('message', this.onPostMessageReceived);
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
}
|
||||
|
||||
@@ -64,11 +115,20 @@ export class HelpTrigger extends React.Component {
|
||||
this.iframeLoadingTimeout = setTimeout(() => {
|
||||
this.setState({ error: url, loading: false });
|
||||
}, IFRAME_TIMEOUT); // safety
|
||||
}
|
||||
};
|
||||
|
||||
onIframeLoaded = () => {
|
||||
this.setState({ loading: false });
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
};
|
||||
|
||||
onPostMessageReceived = (event) => {
|
||||
const { type, message: currentUrl } = event.data || {};
|
||||
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ currentUrl });
|
||||
}
|
||||
|
||||
openDrawer = () => {
|
||||
@@ -78,25 +138,31 @@ export class HelpTrigger extends React.Component {
|
||||
|
||||
// wait for drawer animation to complete so there's no animation jank
|
||||
setTimeout(() => this.loadIframe(url), 300);
|
||||
}
|
||||
};
|
||||
|
||||
closeDrawer = () => {
|
||||
this.setState({ visible: false });
|
||||
closeDrawer = (event) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
this.setState({ visible: false });
|
||||
this.setState({ visible: false, currentUrl: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const [, tooltip] = TYPES[this.props.type];
|
||||
const className = cx('help-trigger', this.props.className);
|
||||
const url = this.state.currentUrl;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Tooltip title={tooltip}>
|
||||
<a href="javascript: void(0)" onClick={this.openDrawer} className={className}>
|
||||
<i className="fa fa-question-circle" />
|
||||
<a onClick={this.openDrawer} className={className}>
|
||||
{this.props.children}
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Drawer
|
||||
placement="right"
|
||||
closable={false}
|
||||
onClose={this.closeDrawer}
|
||||
visible={this.state.visible}
|
||||
className="help-drawer"
|
||||
@@ -104,6 +170,22 @@ export class HelpTrigger extends React.Component {
|
||||
width={400}
|
||||
>
|
||||
<div className="drawer-wrapper">
|
||||
<div className="drawer-menu">
|
||||
{url && (
|
||||
<Tooltip title="Open page in a new window" placement="left">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href={url} target="_blank">
|
||||
<i className="fa fa-external-link" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Close" placement="bottom">
|
||||
<a href="#" onClick={this.closeDrawer}>
|
||||
<Icon type="close" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* iframe */}
|
||||
{!this.state.error && (
|
||||
<iframe
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
@import '~antd/lib/drawer/style/drawer';
|
||||
|
||||
@help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15
|
||||
|
||||
.help-trigger {
|
||||
font-size: 15px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.help-drawer {
|
||||
@@ -20,6 +28,54 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
top: 13px;
|
||||
right: 13px;
|
||||
border-radius: 3px;
|
||||
background: rgba(@help-doc-bg, .75); // makes it dissolve over help doc bg
|
||||
border: 2px solid @help-doc-bg;
|
||||
display: flex;
|
||||
|
||||
a {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: @text-color-secondary;
|
||||
transition: color @animation-duration-slow;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: @icon-color-hover;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.fa-external-link {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// divider
|
||||
&:not(:first-child):before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 9px;
|
||||
left: 0;
|
||||
top: 9px;
|
||||
border-left: 1px dotted rgba(0,0,0,.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 0;
|
||||
visibility: hidden;
|
||||
|
||||
20
client/app/components/HtmlContent.jsx
Normal file
20
client/app/components/HtmlContent.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { $sanitize } from '@/services/ng';
|
||||
|
||||
export default function HtmlContent({ children, ...props }) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
dangerouslySetInnerHTML={{ __html: $sanitize(children) }} // eslint-disable-line react/no-danger
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
HtmlContent.propTypes = {
|
||||
children: PropTypes.string,
|
||||
};
|
||||
|
||||
HtmlContent.defaultProps = {
|
||||
children: '',
|
||||
};
|
||||
32
client/app/components/ParameterApplyButton.jsx
Normal file
32
client/app/components/ParameterApplyButton.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from 'antd/lib/button';
|
||||
import Badge from 'antd/lib/badge';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import { KeyboardShortcuts } from '@/services/keyboard-shortcuts';
|
||||
|
||||
function ParameterApplyButton({ paramCount, onClick }) {
|
||||
// show spinner when count is empty so the fade out is consistent
|
||||
const icon = !paramCount ? 'spinner fa-pulse' : 'check';
|
||||
|
||||
return (
|
||||
<div className="parameter-apply-button" data-show={!!paramCount} data-test="ParameterApplyButton">
|
||||
<Badge count={paramCount}>
|
||||
<Tooltip title={`${KeyboardShortcuts.modKey} + Enter`}>
|
||||
<span>
|
||||
<Button onClick={onClick}>
|
||||
<i className={`fa fa-${icon}`} /> Apply Changes
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ParameterApplyButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
paramCount: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default ParameterApplyButton;
|
||||
@@ -14,10 +14,9 @@ import Input from 'antd/lib/input';
|
||||
import Radio from 'antd/lib/radio';
|
||||
import Form from 'antd/lib/form';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import { ParameterValueInput } from '@/components/ParameterValueInput';
|
||||
import ParameterValueInput from '@/components/ParameterValueInput';
|
||||
import { ParameterMappingType } from '@/services/widget';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import { Query, Parameter } from '@/services/query';
|
||||
import { Parameter } from '@/services/query';
|
||||
import { HelpTrigger } from '@/components/HelpTrigger';
|
||||
|
||||
import './ParameterMappingInput.less';
|
||||
@@ -120,8 +119,6 @@ export class ParameterMappingInput extends React.Component {
|
||||
mapping: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
existingParamNames: PropTypes.arrayOf(PropTypes.string),
|
||||
onChange: PropTypes.func,
|
||||
clientConfig: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
Query: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
inputError: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -129,8 +126,6 @@ export class ParameterMappingInput extends React.Component {
|
||||
mapping: {},
|
||||
existingParamNames: [],
|
||||
onChange: () => {},
|
||||
clientConfig: null,
|
||||
Query: null,
|
||||
inputError: null,
|
||||
};
|
||||
|
||||
@@ -159,6 +154,10 @@ export class ParameterMappingInput extends React.Component {
|
||||
updateParamMapping = (update) => {
|
||||
const { onChange, mapping } = this.props;
|
||||
const newMapping = extend({}, mapping, update);
|
||||
if (newMapping.value !== mapping.value) {
|
||||
newMapping.param = newMapping.param.clone();
|
||||
newMapping.param.setValue(newMapping.value);
|
||||
}
|
||||
onChange(newMapping);
|
||||
};
|
||||
|
||||
@@ -228,9 +227,8 @@ export class ParameterMappingInput extends React.Component {
|
||||
value={mapping.param.normalizedValue}
|
||||
enumOptions={mapping.param.enumOptions}
|
||||
queryId={mapping.param.queryId}
|
||||
parameter={mapping.param}
|
||||
onSelect={value => this.updateParamMapping({ value })}
|
||||
clientConfig={this.props.clientConfig}
|
||||
Query={this.props.Query}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -345,8 +343,6 @@ class MappingEditor extends React.Component {
|
||||
mapping={mapping}
|
||||
existingParamNames={this.props.existingParamNames}
|
||||
onChange={this.onChange}
|
||||
clientConfig={clientConfig}
|
||||
Query={Query}
|
||||
inputError={inputError}
|
||||
/>
|
||||
<footer>
|
||||
@@ -540,7 +536,13 @@ export class ParameterMappingListInput extends React.Component {
|
||||
param = param.clone().setValue(mapping.value);
|
||||
}
|
||||
|
||||
const value = Parameter.getValue(param);
|
||||
let value = Parameter.getValue(param);
|
||||
|
||||
// in case of dynamic value display the name instead of value
|
||||
if (param.hasDynamicValue) {
|
||||
value = param.dynamicValue.name;
|
||||
}
|
||||
|
||||
return this.getStringValue(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Select from 'antd/lib/select';
|
||||
import Input from 'antd/lib/input';
|
||||
import InputNumber from 'antd/lib/input-number';
|
||||
import { DateInput } from './DateInput';
|
||||
import { DateRangeInput } from './DateRangeInput';
|
||||
import { DateTimeInput } from './DateTimeInput';
|
||||
import { DateTimeRangeInput } from './DateTimeRangeInput';
|
||||
import DateParameter from '@/components/dynamic-parameters/DateParameter';
|
||||
import DateRangeParameter from '@/components/dynamic-parameters/DateRangeParameter';
|
||||
import { toString } from 'lodash';
|
||||
import { QueryBasedParameterInput } from './QueryBasedParameterInput';
|
||||
|
||||
import './ParameterValueInput.less';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export class ParameterValueInput extends React.Component {
|
||||
const multipleValuesProps = {
|
||||
maxTagCount: 3,
|
||||
maxTagTextLength: 10,
|
||||
maxTagPlaceholder: num => `+${num.length} more`,
|
||||
};
|
||||
|
||||
class ParameterValueInput extends React.Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.string,
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
enumOptions: PropTypes.string,
|
||||
queryId: PropTypes.number,
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
allowMultipleValues: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
@@ -27,89 +35,83 @@ export class ParameterValueInput extends React.Component {
|
||||
value: null,
|
||||
enumOptions: '',
|
||||
queryId: null,
|
||||
parameter: null,
|
||||
allowMultipleValues: false,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
renderDateTimeWithSecondsInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: props.parameter.hasPendingValue ? props.parameter.pendingValue : props.value,
|
||||
isDirty: props.parameter.hasPendingValue,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const { value, parameter } = this.props;
|
||||
// if value prop updated, reset dirty state
|
||||
if (prevProps.value !== value || prevProps.parameter !== parameter) {
|
||||
this.setState({
|
||||
value: parameter.hasPendingValue ? parameter.pendingValue : value,
|
||||
isDirty: parameter.hasPendingValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onSelect = (value) => {
|
||||
const isDirty = toString(value) !== toString(this.props.value);
|
||||
this.setState({ value, isDirty });
|
||||
this.props.onSelect(value, isDirty);
|
||||
}
|
||||
|
||||
renderDateParameter() {
|
||||
const { type, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<DateTimeInput
|
||||
<DateParameter
|
||||
type={type}
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
withSeconds
|
||||
parameter={parameter}
|
||||
onSelect={this.onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateTimeInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
renderDateRangeParameter() {
|
||||
const { type, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<DateTimeInput
|
||||
<DateRangeParameter
|
||||
type={type}
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
return (
|
||||
<DateInput
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateTimeRangeWithSecondsInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
return (
|
||||
<DateTimeRangeInput
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
withSeconds
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateTimeRangeInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
return (
|
||||
<DateTimeRangeInput
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateRangeInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
return (
|
||||
<DateRangeInput
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
parameter={parameter}
|
||||
onSelect={this.onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderEnumInput() {
|
||||
const { value, onSelect, enumOptions } = this.props;
|
||||
const { enumOptions, allowMultipleValues } = this.props;
|
||||
const { value } = this.state;
|
||||
const enumOptionsArray = enumOptions.split('\n').filter(v => v !== '');
|
||||
return (
|
||||
<Select
|
||||
className={this.props.className}
|
||||
mode={allowMultipleValues ? 'multiple' : 'default'}
|
||||
optionFilterProp="children"
|
||||
disabled={enumOptionsArray.length === 0}
|
||||
defaultValue={value}
|
||||
onChange={onSelect}
|
||||
value={value}
|
||||
onChange={this.onSelect}
|
||||
dropdownMatchSelectWidth={false}
|
||||
dropdownClassName="ant-dropdown-in-bootstrap-modal"
|
||||
showSearch
|
||||
showArrow
|
||||
style={{ minWidth: 60 }}
|
||||
notFoundContent={null}
|
||||
{...multipleValuesProps}
|
||||
>
|
||||
{enumOptionsArray.map(option => (<Option key={option} value={option}>{ option }</Option>))}
|
||||
</Select>
|
||||
@@ -117,78 +119,77 @@ export class ParameterValueInput extends React.Component {
|
||||
}
|
||||
|
||||
renderQueryBasedInput() {
|
||||
const { value, onSelect, queryId } = this.props;
|
||||
const { queryId, parameter, allowMultipleValues } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<QueryBasedParameterInput
|
||||
className={this.props.className}
|
||||
mode={allowMultipleValues ? 'multiple' : 'default'}
|
||||
optionFilterProp="children"
|
||||
parameter={parameter}
|
||||
value={value}
|
||||
queryId={queryId}
|
||||
onSelect={onSelect}
|
||||
onSelect={this.onSelect}
|
||||
style={{ minWidth: 60 }}
|
||||
{...multipleValuesProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderNumberInput() {
|
||||
const { value, onSelect, className } = this.props;
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
const normalize = val => (isNaN(val) ? undefined : val);
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
className={'form-control ' + className}
|
||||
defaultValue={!isNaN(value) && value || 0}
|
||||
onChange={onSelect}
|
||||
className={className}
|
||||
value={normalize(value)}
|
||||
onChange={val => this.onSelect(normalize(val))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderTextInput() {
|
||||
const { value, onSelect, className } = this.props;
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
return (
|
||||
<Input
|
||||
className={'form-control ' + className}
|
||||
defaultValue={value || ''}
|
||||
onChange={event => onSelect(event.target.value)}
|
||||
className={className}
|
||||
value={value}
|
||||
data-test="TextParamInput"
|
||||
onChange={e => this.onSelect(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
renderInput() {
|
||||
const { type } = this.props;
|
||||
switch (type) {
|
||||
case 'datetime-with-seconds': return this.renderDateTimeWithSecondsInput();
|
||||
case 'datetime-local': return this.renderDateTimeInput();
|
||||
case 'date': return this.renderDateInput();
|
||||
case 'datetime-range-with-seconds': return this.renderDateTimeRangeWithSecondsInput();
|
||||
case 'datetime-range': return this.renderDateTimeRangeInput();
|
||||
case 'date-range': return this.renderDateRangeInput();
|
||||
case 'datetime-with-seconds':
|
||||
case 'datetime-local':
|
||||
case 'date': return this.renderDateParameter();
|
||||
case 'datetime-range-with-seconds':
|
||||
case 'datetime-range':
|
||||
case 'date-range': return this.renderDateRangeParameter();
|
||||
case 'enum': return this.renderEnumInput();
|
||||
case 'query': return this.renderQueryBasedInput();
|
||||
case 'number': return this.renderNumberInput();
|
||||
default: return this.renderTextInput();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isDirty } = this.state;
|
||||
|
||||
return (
|
||||
<div className="parameter-input" data-dirty={isDirty || null}>
|
||||
{this.renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('parameterValueInput', {
|
||||
template: `
|
||||
<parameter-value-input-impl
|
||||
type="$ctrl.param.type"
|
||||
value="$ctrl.param.normalizedValue"
|
||||
enum-options="$ctrl.param.enumOptions"
|
||||
query-id="$ctrl.param.queryId"
|
||||
on-select="$ctrl.setValue"
|
||||
></parameter-value-input-impl>
|
||||
`,
|
||||
bindings: {
|
||||
param: '<',
|
||||
},
|
||||
controller($scope) {
|
||||
this.setValue = (value) => {
|
||||
this.param.setValue(value);
|
||||
$scope.$applyAsync();
|
||||
};
|
||||
},
|
||||
});
|
||||
ngModule.component('parameterValueInputImpl', react2angular(ParameterValueInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default ParameterValueInput;
|
||||
|
||||
26
client/app/components/ParameterValueInput.less
Normal file
26
client/app/components/ParameterValueInput.less
Normal file
@@ -0,0 +1,26 @@
|
||||
@import '~antd/lib/input-number/style/index'; // for ant @vars
|
||||
|
||||
@input-dirty: #fffce1;
|
||||
|
||||
.parameter-input {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.@{ant-prefix}-input,
|
||||
.@{ant-prefix}-input-number {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
.@{ant-prefix}-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&[data-dirty] {
|
||||
.@{ant-prefix}-input, // covers also ant date component
|
||||
.@{ant-prefix}-input-number,
|
||||
.@{ant-prefix}-select-selection {
|
||||
background-color: @input-dirty;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
client/app/components/Parameters.jsx
Normal file
208
client/app/components/Parameters.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { size, filter, forEach, extend } from 'lodash';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { sortableContainer, sortableElement, sortableHandle } from 'react-sortable-hoc';
|
||||
import { $location } from '@/services/ng';
|
||||
import { Parameter } from '@/services/query';
|
||||
import ParameterApplyButton from '@/components/ParameterApplyButton';
|
||||
import ParameterValueInput from '@/components/ParameterValueInput';
|
||||
import EditParameterSettingsDialog from './EditParameterSettingsDialog';
|
||||
import { toHuman } from '@/filters';
|
||||
|
||||
import './Parameters.less';
|
||||
|
||||
const DragHandle = sortableHandle(({ parameterName }) => (
|
||||
<div className="drag-handle" data-test={`DragHandle-${parameterName}`} />
|
||||
));
|
||||
|
||||
const SortableItem = sortableElement(({ className, parameterName, disabled, children }) => (
|
||||
<div className={className} data-editable={!disabled || null}>
|
||||
{!disabled && <DragHandle parameterName={parameterName} />}
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
const SortableContainer = sortableContainer(({ children }) => children);
|
||||
|
||||
function updateUrl(parameters) {
|
||||
const params = extend({}, $location.search());
|
||||
parameters.forEach((param) => {
|
||||
extend(params, param.toUrlParams());
|
||||
});
|
||||
Object.keys(params).forEach(key => params[key] == null && delete params[key]);
|
||||
$location.search(params);
|
||||
}
|
||||
|
||||
export class Parameters extends React.Component {
|
||||
static propTypes = {
|
||||
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
||||
editable: PropTypes.bool,
|
||||
disableUrlUpdate: PropTypes.bool,
|
||||
onValuesChange: PropTypes.func,
|
||||
onPendingValuesChange: PropTypes.func,
|
||||
onParametersEdit: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
parameters: [],
|
||||
editable: false,
|
||||
disableUrlUpdate: false,
|
||||
onValuesChange: () => {},
|
||||
onPendingValuesChange: () => {},
|
||||
onParametersEdit: () => {},
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { parameters } = props;
|
||||
this.state = { parameters, dragging: false };
|
||||
if (!props.disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const { parameters, disableUrlUpdate } = this.props;
|
||||
if (prevProps.parameters !== parameters) {
|
||||
this.setState({ parameters });
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
// Cmd/Ctrl/Alt + Enter
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
||||
e.stopPropagation();
|
||||
this.applyChanges();
|
||||
}
|
||||
};
|
||||
|
||||
setPendingValue = (param, value, isDirty) => {
|
||||
const { onPendingValuesChange } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
if (isDirty) {
|
||||
param.setPendingValue(value);
|
||||
} else {
|
||||
param.clearPendingValue();
|
||||
}
|
||||
onPendingValuesChange();
|
||||
return { parameters };
|
||||
});
|
||||
};
|
||||
|
||||
moveParameter = ({ oldIndex, newIndex }) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
if (oldIndex !== newIndex) {
|
||||
this.setState(({ parameters }) => {
|
||||
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
||||
onParametersEdit();
|
||||
return { parameters };
|
||||
});
|
||||
}
|
||||
this.setState({ dragging: false });
|
||||
};
|
||||
|
||||
onBeforeSortStart = () => {
|
||||
this.setState({ dragging: true });
|
||||
};
|
||||
|
||||
applyChanges = () => {
|
||||
const { onValuesChange, disableUrlUpdate } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue);
|
||||
forEach(parameters, p => p.applyPendingValue());
|
||||
onValuesChange(parametersWithPendingValues);
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
return { parameters };
|
||||
});
|
||||
};
|
||||
|
||||
showParameterSettings = (parameter, index) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
EditParameterSettingsDialog
|
||||
.showModal({ parameter })
|
||||
.result.then((updated) => {
|
||||
this.setState(({ parameters }) => {
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = new Parameter(updatedParameter, updatedParameter.parentQueryId);
|
||||
onParametersEdit();
|
||||
return { parameters };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
renderParameter(param, index) {
|
||||
const { editable } = this.props;
|
||||
return (
|
||||
<div
|
||||
key={param.name}
|
||||
className="di-block"
|
||||
data-test={`ParameterName-${param.name}`}
|
||||
>
|
||||
<div className="parameter-heading">
|
||||
<label>{param.title || toHuman(param.name)}</label>
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-default btn-xs m-l-5"
|
||||
onClick={() => this.showParameterSettings(param, index)}
|
||||
data-test={`ParameterSettings-${param.name}`}
|
||||
type="button"
|
||||
>
|
||||
<i className="fa fa-cog" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ParameterValueInput
|
||||
type={param.type}
|
||||
value={param.normalizedValue}
|
||||
parameter={param}
|
||||
enumOptions={param.enumOptions}
|
||||
queryId={param.queryId}
|
||||
allowMultipleValues={!!param.multiValuesOptions}
|
||||
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { parameters, dragging } = this.state;
|
||||
const { editable } = this.props;
|
||||
const dirtyParamCount = size(filter(parameters, 'hasPendingValue'));
|
||||
return (
|
||||
<SortableContainer
|
||||
axis="xy"
|
||||
useDragHandle
|
||||
lockToContainerEdges
|
||||
helperClass="parameter-dragged"
|
||||
updateBeforeSortStart={this.onBeforeSortStart}
|
||||
onSortEnd={this.moveParameter}
|
||||
>
|
||||
<div
|
||||
className="parameter-container"
|
||||
onKeyDown={dirtyParamCount ? this.handleKeyDown : null}
|
||||
data-draggable={editable || null}
|
||||
data-dragging={dragging || null}
|
||||
>
|
||||
{parameters.map((param, index) => (
|
||||
<SortableItem className="parameter-block" key={param.name} index={index} parameterName={param.name} disabled={!editable}>
|
||||
{this.renderParameter(param, index)}
|
||||
</SortableItem>
|
||||
))}
|
||||
|
||||
<ParameterApplyButton onClick={this.applyChanges} paramCount={dirtyParamCount} />
|
||||
</div>
|
||||
</SortableContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('parameters', react2angular(Parameters));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
124
client/app/components/Parameters.less
Normal file
124
client/app/components/Parameters.less
Normal file
@@ -0,0 +1,124 @@
|
||||
@import '../assets/less/ant';
|
||||
|
||||
.drag-handle {
|
||||
background: linear-gradient(90deg, transparent 0px, white 1px, white 2px)
|
||||
center,
|
||||
linear-gradient(transparent 0px, white 1px, white 2px) center, #111111;
|
||||
background-size: 2px 2px;
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 36px;
|
||||
vertical-align: bottom;
|
||||
margin-right: 5px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.parameter-block {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
padding: 0 12px 6px 0;
|
||||
vertical-align: top;
|
||||
|
||||
.parameter-container[data-draggable] & {
|
||||
margin: 4px 0 0 4px;
|
||||
padding: 3px 6px 6px;
|
||||
}
|
||||
|
||||
&.parameter-dragged {
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 4px;
|
||||
|
||||
label {
|
||||
margin-bottom: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 100%;
|
||||
max-width: 195px;
|
||||
white-space: nowrap;
|
||||
|
||||
.parameter-block[data-editable] & {
|
||||
min-width: calc(100% - 27px); // make room for settings button
|
||||
max-width: 195px - 27px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-container {
|
||||
position: relative;
|
||||
|
||||
&[data-draggable] {
|
||||
padding: 0 4px 4px 0;
|
||||
transition: background-color 200ms ease-out;
|
||||
transition-delay: 300ms; // short pause before returning to original bgcolor
|
||||
}
|
||||
|
||||
&[data-dragging] {
|
||||
transition-delay: 0s;
|
||||
background-color: #f6f8f9;
|
||||
}
|
||||
|
||||
.parameter-apply-button {
|
||||
display: none; // default for mobile
|
||||
|
||||
// "floating" on desktop
|
||||
@media (min-width: 768px) {
|
||||
position: absolute;
|
||||
bottom: -36px;
|
||||
left: -15px;
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
transition: opacity 150ms ease-out;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
background-color: #ffffff;
|
||||
padding: 4px;
|
||||
padding-left: 16px;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
pointer-events: none; // so tooltip doesn't remain after button hides
|
||||
}
|
||||
|
||||
&[data-show="true"] {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0 8px 0 6px;
|
||||
color: #2096f3;
|
||||
border-color: #50acf6;
|
||||
|
||||
// smaller on desktop
|
||||
@media (min-width: 768px) {
|
||||
font-size: 12px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
background-color: #eef7fe;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-badge-count {
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
background: #f77b74;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0px 0px 0 1px white, -1px 1px 0 1px #5d6f7d85;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
// PreviewCard
|
||||
|
||||
export function PreviewCard({ imageUrl, title, body, children, className, ...props }) {
|
||||
export function PreviewCard({ imageUrl, roundedImage, title, body, children, className, ...props }) {
|
||||
return (
|
||||
<div {...props} className={className + ' w-100 d-flex align-items-center'}>
|
||||
<img src={imageUrl} width="32" height="32" className="profile__image--settings m-r-5" alt="Logo/Avatar" />
|
||||
<img
|
||||
src={imageUrl}
|
||||
width="32"
|
||||
height="32"
|
||||
className={classNames({ 'profile__image--settings': roundedImage }, 'm-r-5')}
|
||||
alt="Logo/Avatar"
|
||||
/>
|
||||
<div className="flex-fill">
|
||||
<div>{title}</div>
|
||||
{body && <div className="text-muted">{body}</div>}
|
||||
@@ -20,12 +27,14 @@ PreviewCard.propTypes = {
|
||||
imageUrl: PropTypes.string.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
body: PropTypes.node,
|
||||
roundedImage: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
PreviewCard.defaultProps = {
|
||||
body: null,
|
||||
roundedImage: true,
|
||||
className: '',
|
||||
children: null,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { find, isFunction } from 'lodash';
|
||||
import { find, isFunction, isArray, isEqual, toString, map, intersection } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Select from 'antd/lib/select';
|
||||
import { Query } from '@/services/query';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export class QueryBasedParameterInput extends React.Component {
|
||||
static propTypes = {
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
mode: PropTypes.oneOf(['default', 'multiple']),
|
||||
queryId: PropTypes.number,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
@@ -17,6 +18,8 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
|
||||
static defaultProps = {
|
||||
value: null,
|
||||
mode: 'default',
|
||||
parameter: null,
|
||||
queryId: null,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
@@ -34,31 +37,39 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
this._loadOptions(this.props.queryId);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.queryId !== this.props.queryId) {
|
||||
this._loadOptions(nextProps.queryId, nextProps.value);
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.queryId !== prevProps.queryId) {
|
||||
this._loadOptions(this.props.queryId);
|
||||
}
|
||||
}
|
||||
|
||||
_loadOptions(queryId) {
|
||||
async _loadOptions(queryId) {
|
||||
if (queryId && (queryId !== this.state.queryId)) {
|
||||
this.setState({ loading: true });
|
||||
Query.dropdownOptions({ id: queryId }, (options) => {
|
||||
const options = await this.props.parameter.loadDropdownValues();
|
||||
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
this.setState({ options, loading: false });
|
||||
|
||||
if (this.props.mode === 'multiple' && isArray(this.props.value)) {
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(this.props.value, optionValues);
|
||||
if (!isEqual(this.props.value, validValues)) {
|
||||
this.props.onSelect(validValues);
|
||||
}
|
||||
} else {
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
if (!found && isFunction(this.props.onSelect)) {
|
||||
this.props.onSelect(options[0].value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, value, onSelect } = this.props;
|
||||
const { className, value, mode, onSelect, ...otherProps } = this.props;
|
||||
const { loading, options } = this.state;
|
||||
return (
|
||||
<span>
|
||||
@@ -66,10 +77,15 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
className={className}
|
||||
disabled={loading || (options.length === 0)}
|
||||
loading={loading}
|
||||
defaultValue={'' + value}
|
||||
mode={mode}
|
||||
value={isArray(value) ? value : toString(value)}
|
||||
onChange={onSelect}
|
||||
dropdownMatchSelectWidth={false}
|
||||
dropdownClassName="ant-dropdown-in-bootstrap-modal"
|
||||
optionFilterProp="children"
|
||||
showSearch
|
||||
showArrow
|
||||
notFoundContent={null}
|
||||
{...otherProps}
|
||||
>
|
||||
{options.map(option => (<Option value={option.value} key={option.value}>{option.name}</Option>))}
|
||||
</Select>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { react2angular } from 'react2angular';
|
||||
|
||||
import AceEditor from 'react-ace';
|
||||
import ace from 'brace';
|
||||
import toastr from 'angular-toastr';
|
||||
import notification from '@/services/notification';
|
||||
|
||||
import 'brace/ext/language_tools';
|
||||
import 'brace/mode/json';
|
||||
@@ -54,7 +54,7 @@ class QueryEditor extends React.Component {
|
||||
isDirty: PropTypes.bool.isRequired,
|
||||
isQueryOwner: PropTypes.bool.isRequired,
|
||||
updateDataSource: PropTypes.func.isRequired,
|
||||
canExecuteQuery: PropTypes.func.isRequired,
|
||||
canExecuteQuery: PropTypes.bool.isRequired,
|
||||
executeQuery: PropTypes.func.isRequired,
|
||||
queryExecuting: PropTypes.bool.isRequired,
|
||||
saveQuery: PropTypes.func.isRequired,
|
||||
@@ -149,6 +149,7 @@ class QueryEditor extends React.Component {
|
||||
editor.commands.bindKey({ win: 'Ctrl+P', mac: null }, null);
|
||||
// Lineup only mac
|
||||
editor.commands.bindKey({ win: null, mac: 'Ctrl+P' }, 'golineup');
|
||||
editor.commands.bindKey({ win: 'Ctrl+Shift+F', mac: 'Cmd+Shift+F' }, this.formatQuery);
|
||||
|
||||
// Reset Completer in case dot is pressed
|
||||
editor.commands.on('afterExec', (e) => {
|
||||
@@ -209,7 +210,7 @@ class QueryEditor extends React.Component {
|
||||
formatQuery = () => {
|
||||
Query.format(this.props.dataSource.syntax || 'sql', this.props.queryText)
|
||||
.then(this.updateQuery)
|
||||
.catch(error => toastr.error(error));
|
||||
.catch(error => notification.error(error));
|
||||
};
|
||||
|
||||
toggleAutocomplete = (state) => {
|
||||
@@ -226,7 +227,7 @@ class QueryEditor extends React.Component {
|
||||
render() {
|
||||
const modKey = KeyboardShortcuts.modKey;
|
||||
|
||||
const isExecuteDisabled = this.props.queryExecuting || !this.props.canExecuteQuery();
|
||||
const isExecuteDisabled = this.props.queryExecuting || !this.props.canExecuteQuery;
|
||||
|
||||
return (
|
||||
<section style={{ height: '100%' }} data-test="QueryEditor">
|
||||
@@ -266,7 +267,7 @@ class QueryEditor extends React.Component {
|
||||
{{ }}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="top" title="Format Query">
|
||||
<Tooltip placement="top" title={<>Format Query (<i>{modKey} + Shift + F</i>)</>}>
|
||||
<button type="button" className="btn btn-default m-r-5" onClick={this.formatQuery}>
|
||||
<span className="zmdi zmdi-format-indent-increase" />
|
||||
</button>
|
||||
@@ -289,7 +290,13 @@ class QueryEditor extends React.Component {
|
||||
</select>
|
||||
{this.props.canEdit ? (
|
||||
<Tooltip placement="top" title={modKey + ' + S'}>
|
||||
<button type="button" className="btn btn-default m-l-5" onClick={this.props.saveQuery} title="Save">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-default m-l-5"
|
||||
onClick={this.props.saveQuery}
|
||||
data-test="SaveButton"
|
||||
title="Save"
|
||||
>
|
||||
<span className="fa fa-floppy-o" />
|
||||
<span className="hidden-xs m-l-5">Save</span>
|
||||
{this.props.isDirty ? '*' : null}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { debounce, find } from 'lodash';
|
||||
import Input from 'antd/lib/input';
|
||||
import Select from 'antd/lib/select';
|
||||
import { Query } from '@/services/query';
|
||||
import { toastr } from '@/services/ng';
|
||||
import notification from '@/services/notification';
|
||||
import { QueryTagsControl } from '@/components/tags-control/TagsControl';
|
||||
|
||||
const SEARCH_DEBOUNCE_DURATION = 200;
|
||||
@@ -94,7 +94,7 @@ export function QuerySelector(props) {
|
||||
if (queryId) {
|
||||
query = find(searchResults, { id: queryId });
|
||||
if (!query) { // shouldn't happen
|
||||
toastr.error('Something went wrong... Couldn\'t select query');
|
||||
notification.error('Something went wrong...', 'Couldn\'t select query');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +112,10 @@ export function QuerySelector(props) {
|
||||
<div className="list-group">
|
||||
{searchResults.map(q => (
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
className={cx('list-group-item', { inactive: q.is_draft })}
|
||||
className={cx('query-selector-result', 'list-group-item', { inactive: q.is_draft })}
|
||||
key={q.id}
|
||||
onClick={() => selectQuery(q.id)}
|
||||
data-test={`QueryId${q.id}`}
|
||||
>
|
||||
{q.name}
|
||||
{' '}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
|
||||
import { BigMessage } from '@/components/BigMessage';
|
||||
|
||||
import LoadingState from '@/components/items-list/components/LoadingState';
|
||||
import { toastr } from '@/services/ng';
|
||||
import notification from '@/services/notification';
|
||||
|
||||
class SelectItemsDialog extends React.Component {
|
||||
static propTypes = {
|
||||
@@ -100,7 +100,7 @@ class SelectItemsDialog extends React.Component {
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({ saveInProgress: false });
|
||||
toastr.error('Failed to save some of selected items.');
|
||||
notification.error('Failed to save some of selected items.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ export class TagsList extends React.Component {
|
||||
{map(allTags, tag => (
|
||||
<a
|
||||
key={tag.name}
|
||||
href="javascript:void(0)"
|
||||
className={classNames('list-group-item', 'max-character', { active: selectedTags.has(tag.name) })}
|
||||
onClick={event => this.toggleTag(event, tag.name)}
|
||||
>
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import moment from 'moment';
|
||||
import { isNil } from 'lodash';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import useForceUpdate from '@/lib/hooks/useForceUpdate';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
|
||||
const autoUpdateList = new Set();
|
||||
|
||||
function updateComponents() {
|
||||
autoUpdateList.forEach(component => component.update());
|
||||
setTimeout(updateComponents, 30 * 1000);
|
||||
function toMoment(value) {
|
||||
value = !isNil(value) ? moment(value) : null;
|
||||
return value && value.isValid() ? value : null;
|
||||
}
|
||||
updateComponents();
|
||||
|
||||
export class TimeAgo extends React.PureComponent {
|
||||
static propTypes = {
|
||||
export function TimeAgo({ date, placeholder, autoUpdate }) {
|
||||
const startDate = toMoment(date);
|
||||
|
||||
const value = startDate ? startDate.fromNow() : placeholder;
|
||||
const title = startDate ? startDate.format(clientConfig.dateTimeFormat) : '';
|
||||
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
if (autoUpdate) {
|
||||
const timer = setInterval(forceUpdate, 30 * 1000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [autoUpdate]);
|
||||
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<span data-test="TimeAgo">{value}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
TimeAgo.propTypes = {
|
||||
// `date` and `placeholder` used in `getDerivedStateFromProps`
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
date: PropTypes.oneOfType([
|
||||
@@ -27,57 +47,24 @@ export class TimeAgo extends React.PureComponent {
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
placeholder: PropTypes.string,
|
||||
autoUpdate: PropTypes.bool,
|
||||
};
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
TimeAgo.defaultProps = {
|
||||
date: null,
|
||||
placeholder: '',
|
||||
autoUpdate: true,
|
||||
};
|
||||
|
||||
// Initial state, to get rid of React warning
|
||||
state = {
|
||||
title: null,
|
||||
value: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps({ date, placeholder }) {
|
||||
// if `date` prop is not empty and a valid date/time - convert it to `moment`
|
||||
date = !isNil(date) ? moment(date) : null;
|
||||
date = date && date.isValid() ? date : null;
|
||||
|
||||
return {
|
||||
value: date ? date.fromNow() : placeholder,
|
||||
title: date ? date.format(clientConfig.dateTimeFormat) : '',
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
autoUpdateList.add(this);
|
||||
this.update(true);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
autoUpdateList.delete(this);
|
||||
}
|
||||
|
||||
update(force = false) {
|
||||
if (force || this.props.autoUpdate) {
|
||||
this.setState(this.constructor.getDerivedStateFromProps(this.props));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <span title={this.state.title} data-test="TimeAgo">{this.state.value}</span>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('amTimeAgo', () => ({
|
||||
link($scope, element, attr) {
|
||||
link($scope, $element, attr) {
|
||||
const modelName = attr.amTimeAgo;
|
||||
$scope.$watch(modelName, (value) => {
|
||||
ReactDOM.render(<TimeAgo date={value} />, element[0]);
|
||||
ReactDOM.render(<TimeAgo date={value} />, $element[0]);
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
ReactDOM.unmountComponentAtNode($element[0]);
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -91,6 +78,10 @@ export default function init(ngModule) {
|
||||
// Initial render will occur here as well
|
||||
ReactDOM.render(<TimeAgo date={this.value} placeholder="-" />, $element[0]);
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
ReactDOM.unmountComponentAtNode($element[0]);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
40
client/app/components/Timer.jsx
Normal file
40
client/app/components/Timer.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import moment from 'moment';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
import useForceUpdate from '@/lib/hooks/useForceUpdate';
|
||||
|
||||
export function Timer({ from }) {
|
||||
const startTime = useMemo(() => moment(from).valueOf(), [from]);
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(forceUpdate, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const diff = moment.now() - startTime;
|
||||
const format = diff > 1000 * 60 * 60 ? 'HH:mm:ss' : 'mm:ss'; // no HH under an hour
|
||||
|
||||
return moment.utc(diff).format(format);
|
||||
}
|
||||
|
||||
Timer.propTypes = {
|
||||
from: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.instanceOf(Date),
|
||||
Moment,
|
||||
]),
|
||||
};
|
||||
|
||||
Timer.defaultProps = {
|
||||
from: null,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('rdTimer', react2angular(Timer));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -1,60 +1,16 @@
|
||||
import { map } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { $http } from '@/services/ng';
|
||||
|
||||
import Table from 'antd/lib/table';
|
||||
import Col from 'antd/lib/col';
|
||||
import Row from 'antd/lib/row';
|
||||
import Card from 'antd/lib/card';
|
||||
import Spin from 'antd/lib/spin';
|
||||
import Badge from 'antd/lib/badge';
|
||||
import Tabs from 'antd/lib/tabs';
|
||||
import Alert from 'antd/lib/alert';
|
||||
import moment from 'moment';
|
||||
import values from 'lodash/values';
|
||||
import { Columns } from '@/components/items-list/components/ItemsTable';
|
||||
|
||||
function parseTasks(tasks) {
|
||||
const queues = {};
|
||||
const queries = [];
|
||||
const otherTasks = [];
|
||||
// CounterCard
|
||||
|
||||
const counters = { active: 0, reserved: 0, waiting: 0 };
|
||||
|
||||
tasks.forEach((task) => {
|
||||
queues[task.queue] = queues[task.queue] || { name: task.queue, active: 0, reserved: 0, waiting: 0 };
|
||||
queues[task.queue][task.state] += 1;
|
||||
|
||||
if (task.enqueue_time) {
|
||||
task.enqueue_time = moment(task.enqueue_time * 1000.0);
|
||||
}
|
||||
if (task.start_time) {
|
||||
task.start_time = moment(task.start_time * 1000.0);
|
||||
}
|
||||
|
||||
counters[task.state] += 1;
|
||||
|
||||
if (task.task_name === 'redash.tasks.execute_query') {
|
||||
queries.push(task);
|
||||
} else {
|
||||
otherTasks.push(task);
|
||||
}
|
||||
});
|
||||
|
||||
return { queues: values(queues), queries, otherTasks, counters };
|
||||
}
|
||||
|
||||
function QueuesTable({ loading, queues }) {
|
||||
const columns = ['Name', 'Active', 'Reserved', 'Waiting'].map(c => ({ title: c, dataIndex: c.toLowerCase() }));
|
||||
|
||||
return <Table columns={columns} rowKey="name" dataSource={queues} loading={loading} />;
|
||||
}
|
||||
|
||||
QueuesTable.propTypes = {
|
||||
loading: PropTypes.bool.isRequired,
|
||||
queues: PropTypes.arrayOf(PropTypes.any).isRequired,
|
||||
};
|
||||
|
||||
function CounterCard({ title, value, loading }) {
|
||||
export function CounterCard({ title, value, loading }) {
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Card>
|
||||
@@ -75,145 +31,82 @@ CounterCard.defaultProps = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
export default class AdminCeleryStatus extends React.Component {
|
||||
state = {
|
||||
loading: true,
|
||||
error: false,
|
||||
counters: {},
|
||||
queries: [],
|
||||
otherTasks: [],
|
||||
queues: [],
|
||||
};
|
||||
// Tables
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fetch();
|
||||
const commonColumns = [
|
||||
{ title: 'Worker Name', dataIndex: 'worker' },
|
||||
{ title: 'PID', dataIndex: 'worker_pid' },
|
||||
{ title: 'Queue', dataIndex: 'queue' },
|
||||
Columns.custom((value) => {
|
||||
if (value === 'active') {
|
||||
return <span><Badge status="processing" /> Active</span>;
|
||||
}
|
||||
|
||||
fetch() {
|
||||
// TODO: handle error
|
||||
$http
|
||||
.get('/api/admin/queries/tasks')
|
||||
.then(({ data }) => {
|
||||
const { queues, queries, otherTasks, counters } = parseTasks(data.tasks);
|
||||
this.setState({ loading: false, queries, otherTasks, queues, counters });
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({ loading: false, error: true });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const commonColumns = [
|
||||
{
|
||||
title: 'Worker Name',
|
||||
dataIndex: 'worker',
|
||||
},
|
||||
{
|
||||
title: 'PID',
|
||||
dataIndex: 'worker_pid',
|
||||
},
|
||||
{
|
||||
title: 'Queue',
|
||||
dataIndex: 'queue',
|
||||
},
|
||||
{
|
||||
return <span><Badge status="warning" /> {value}</span>;
|
||||
}, {
|
||||
title: 'State',
|
||||
dataIndex: 'state',
|
||||
render: (value) => {
|
||||
if (value === 'active') {
|
||||
return (
|
||||
<span>
|
||||
<Badge status="processing" /> Active
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
<Badge status="warning" /> {value}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
Columns.timeAgo({ title: 'Start Time', dataIndex: 'start_time' }),
|
||||
];
|
||||
];
|
||||
|
||||
const queryColumns = commonColumns.concat([
|
||||
const queryColumns = commonColumns.concat([
|
||||
Columns.timeAgo({ title: 'Enqueue Time', dataIndex: 'enqueue_time' }),
|
||||
{
|
||||
title: 'Query ID',
|
||||
dataIndex: 'query_id',
|
||||
},
|
||||
{
|
||||
title: 'Org ID',
|
||||
dataIndex: 'org_id',
|
||||
},
|
||||
{
|
||||
title: 'Data Source ID',
|
||||
dataIndex: 'data_source_id',
|
||||
},
|
||||
{
|
||||
title: 'User ID',
|
||||
dataIndex: 'user_id',
|
||||
},
|
||||
{
|
||||
title: 'Scheduled',
|
||||
dataIndex: 'scheduled',
|
||||
},
|
||||
]);
|
||||
{ title: 'Query ID', dataIndex: 'query_id' },
|
||||
{ title: 'Org ID', dataIndex: 'org_id' },
|
||||
{ title: 'Data Source ID', dataIndex: 'data_source_id' },
|
||||
{ title: 'User ID', dataIndex: 'user_id' },
|
||||
{ title: 'Scheduled', dataIndex: 'scheduled' },
|
||||
]);
|
||||
|
||||
const otherTasksColumns = commonColumns.concat([
|
||||
{
|
||||
title: 'Task Name',
|
||||
dataIndex: 'task_name',
|
||||
},
|
||||
]);
|
||||
const otherTasksColumns = commonColumns.concat([
|
||||
{ title: 'Task Name', dataIndex: 'task_name' },
|
||||
]);
|
||||
|
||||
if (this.state.error) {
|
||||
const queuesColumns = map(
|
||||
['Name', 'Active', 'Reserved', 'Waiting'],
|
||||
c => ({ title: c, dataIndex: c.toLowerCase() }),
|
||||
);
|
||||
|
||||
const TablePropTypes = {
|
||||
loading: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
export function QueuesTable({ loading, items }) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<Alert type="error" message="Failed loading status. Please refresh." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<Row gutter={16}>
|
||||
<Col span={4}>
|
||||
<CounterCard title="Active Tasks" value={this.state.counters.active} loading={this.state.loading} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<CounterCard title="Reserved Tasks" value={this.state.counters.reserved} loading={this.state.loading} />
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<CounterCard title="Waiting Tasks" value={this.state.counters.waiting} loading={this.state.loading} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Tabs defaultActiveKey="queues">
|
||||
<Tabs.TabPane key="queues" tab="Queues">
|
||||
<QueuesTable loading={this.state.loading} queues={this.state.queues} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="queries" tab="Queries">
|
||||
<Table
|
||||
rowKey="task_id"
|
||||
dataSource={this.state.queries}
|
||||
loading={this.state.loading}
|
||||
columns={queryColumns}
|
||||
loading={loading}
|
||||
columns={queuesColumns}
|
||||
rowKey="name"
|
||||
dataSource={items}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="other" tab="Other Tasks">
|
||||
<Table
|
||||
rowKey="task_id"
|
||||
dataSource={this.state.otherTasks}
|
||||
loading={this.state.loading}
|
||||
columns={otherTasksColumns}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
QueuesTable.propTypes = TablePropTypes;
|
||||
|
||||
export function QueriesTable({ loading, items }) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={queryColumns}
|
||||
rowKey="task_id"
|
||||
dataSource={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
QueriesTable.propTypes = TablePropTypes;
|
||||
|
||||
export function OtherTasksTable({ loading, items }) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={otherTasksColumns}
|
||||
rowKey="task_id"
|
||||
dataSource={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
OtherTasksTable.propTypes = TablePropTypes;
|
||||
|
||||
38
client/app/components/admin/Layout.jsx
Normal file
38
client/app/components/admin/Layout.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Tabs from 'antd/lib/tabs';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
|
||||
import './layout.less';
|
||||
|
||||
export default function Layout({ activeTab, children }) {
|
||||
return (
|
||||
<div className="container admin-page-layout">
|
||||
<PageHeader title="Admin" />
|
||||
|
||||
<div className="bg-white tiled">
|
||||
<Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false}>
|
||||
<Tabs.TabPane key="system_status" tab={<a href="admin/status">System Status</a>}>
|
||||
{(activeTab === 'system_status') ? children : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="tasks" tab={<a href="admin/queries/tasks">Celery Status</a>}>
|
||||
{(activeTab === 'tasks') ? children : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="outdated_queries" tab={<a href="admin/queries/outdated">Outdated Queries</a>}>
|
||||
{(activeTab === 'outdated_queries') ? children : null}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Layout.propTypes = {
|
||||
activeTab: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
Layout.defaultProps = {
|
||||
activeTab: 'system_status',
|
||||
children: null,
|
||||
};
|
||||
110
client/app/components/admin/StatusBlock.jsx
Normal file
110
client/app/components/admin/StatusBlock.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import { toPairs } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import List from 'antd/lib/list';
|
||||
import Card from 'antd/lib/card';
|
||||
import { TimeAgo } from '@/components/TimeAgo';
|
||||
|
||||
import { toHuman, prettySize } from '@/filters';
|
||||
|
||||
export function General({ info }) {
|
||||
info = toPairs(info);
|
||||
return (
|
||||
<Card title="General" size="small">
|
||||
{(info.length === 0) && (
|
||||
<div className="text-muted text-center">No data</div>
|
||||
)}
|
||||
{(info.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
itemLayout="vertical"
|
||||
dataSource={info}
|
||||
renderItem={([name, value]) => (
|
||||
<List.Item extra={<span className="badge">{value}</span>}>
|
||||
{toHuman(name)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseMetrics({ info }) {
|
||||
return (
|
||||
<Card title="Redash Database" size="small">
|
||||
{(info.length === 0) && (
|
||||
<div className="text-muted text-center">No data</div>
|
||||
)}
|
||||
{(info.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
itemLayout="vertical"
|
||||
dataSource={info}
|
||||
renderItem={([name, size]) => (
|
||||
<List.Item extra={<span className="badge">{prettySize(size)}</span>}>
|
||||
{name}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Queues({ info }) {
|
||||
info = toPairs(info);
|
||||
return (
|
||||
<Card title="Queues" size="small">
|
||||
{(info.length === 0) && (
|
||||
<div className="text-muted text-center">No data</div>
|
||||
)}
|
||||
{(info.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
itemLayout="vertical"
|
||||
dataSource={info}
|
||||
renderItem={([name, queue]) => (
|
||||
<List.Item extra={<span className="badge">{queue.size}</span>}>
|
||||
{name}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Manager({ info }) {
|
||||
const items = info ? [(
|
||||
<List.Item extra={<span className="badge"><TimeAgo date={info.lastRefreshAt} placeholder="n/a" /></span>}>
|
||||
Last Refresh
|
||||
</List.Item>
|
||||
), (
|
||||
<List.Item extra={<span className="badge"><TimeAgo date={info.startedAt} placeholder="n/a" /></span>}>
|
||||
Started
|
||||
</List.Item>
|
||||
), (
|
||||
<List.Item extra={<span className="badge">{info.outdatedQueriesCount}</span>}>
|
||||
Outdated Queries Count
|
||||
</List.Item>
|
||||
)] : [];
|
||||
|
||||
return (
|
||||
<Card title="Manager" size="small">
|
||||
{!info && (
|
||||
<div className="text-muted text-center">No data</div>
|
||||
)}
|
||||
{info && (
|
||||
<List
|
||||
size="small"
|
||||
itemLayout="vertical"
|
||||
dataSource={items}
|
||||
renderItem={item => item}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user