Compare commits
372 Commits
release/7.
...
param-feed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b3f31bdce | ||
|
|
13e5500718 | ||
|
|
5213b524b4 | ||
|
|
e20b2b5dd3 | ||
|
|
ac77587335 | ||
|
|
c553f006d9 | ||
|
|
88ae639ee4 | ||
|
|
ba413c210e | ||
|
|
7157244eec | ||
|
|
9f7844640a | ||
|
|
246eca1121 | ||
|
|
7ffb97232e | ||
|
|
8b9fa53efe | ||
|
|
43b35b6fb4 | ||
|
|
f0f85ece42 | ||
|
|
612833404b | ||
|
|
5d58503623 | ||
|
|
3dfad87266 | ||
|
|
0659ef1079 | ||
|
|
a2e21dd1c3 | ||
|
|
f165cad9ff | ||
|
|
e0a2705c1a | ||
|
|
0aca649cb5 | ||
|
|
79b37e8843 | ||
|
|
72bb5d29a0 | ||
|
|
5a5fdecdde | ||
|
|
f6e1470a7c | ||
|
|
27cd76797e | ||
|
|
29b113005c | ||
|
|
53d971bf87 | ||
|
|
a102e93e50 | ||
|
|
74beed80d2 | ||
|
|
39f038f992 | ||
|
|
da2ed56281 | ||
|
|
9d8812a598 | ||
|
|
204447a9f5 | ||
|
|
3b7efb8c1f | ||
|
|
69dc761c60 | ||
|
|
2f42b8154c | ||
|
|
3f9d49dbd1 | ||
|
|
0a5dca5d72 | ||
|
|
8ea285dda9 | ||
|
|
569c325aa0 | ||
|
|
d8a0af1a95 | ||
|
|
648847df0b | ||
|
|
3f31bf3fc0 | ||
|
|
f6ad3d9d24 | ||
|
|
e8ccdc23c7 | ||
|
|
a8af968d70 | ||
|
|
cb14459881 | ||
|
|
780fbceba5 | ||
|
|
2c77c219c6 | ||
|
|
874e0d1ce3 | ||
|
|
401d164622 | ||
|
|
ff041b77cf | ||
|
|
b2d1636f8e | ||
|
|
a3e8477410 | ||
|
|
ed22b63f22 | ||
|
|
d636b29ba9 | ||
|
|
6173a2a619 | ||
|
|
fd435d2182 | ||
|
|
cb654b3f21 | ||
|
|
e8d40bbdac | ||
|
|
e5d52055d9 | ||
|
|
c5e414e6ba | ||
|
|
b9a40d1808 | ||
|
|
033dd0d15e | ||
|
|
95795d93c7 | ||
|
|
75e48b0bd6 | ||
|
|
75883a1a02 | ||
|
|
75a5546741 | ||
|
|
54071e4b87 | ||
|
|
6458a1eb62 | ||
|
|
ecf160c9bc | ||
|
|
2c98f0425d | ||
|
|
8f01988c8c | ||
|
|
b8741f6cff | ||
|
|
424751d9e9 | ||
|
|
e048a69392 | ||
|
|
2c1e846837 | ||
|
|
4b9e26de5a | ||
|
|
17f50192e7 | ||
|
|
dcdec0abb5 | ||
|
|
302c6dd02e | ||
|
|
4c56900248 | ||
|
|
1f1f853297 | ||
|
|
43f63b1b57 | ||
|
|
5ae80835b1 | ||
|
|
df3da82afd | ||
|
|
98e33b7780 | ||
|
|
8a3f6f90eb | ||
|
|
cab011def9 | ||
|
|
31c888ea8e | ||
|
|
443054428f | ||
|
|
ef9a4d5eed | ||
|
|
a2b68a3569 | ||
|
|
e7b707eb25 | ||
|
|
1786273344 | ||
|
|
d38ca803c5 | ||
|
|
a1f11cb8d9 | ||
|
|
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
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
version: 2.0
|
||||
|
||||
flake8-steps: &steps
|
||||
- checkout
|
||||
- run: sudo pip install flake8
|
||||
- run: ./bin/flake8_tests.sh
|
||||
build-docker-image-job: &build-docker-image-job
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- checkout
|
||||
- run: sudo apt install python3-pip
|
||||
- run: sudo pip3 install -r requirements_bundles.txt
|
||||
- run: .circleci/update_version
|
||||
- run: npm run bundle
|
||||
- run: .circleci/docker_build
|
||||
jobs:
|
||||
python-flake8-tests:
|
||||
backend-lint:
|
||||
docker:
|
||||
- image: circleci/python:3.7.0
|
||||
steps: *steps
|
||||
legacy-python-flake8-tests:
|
||||
docker:
|
||||
- image: circleci/python:2.7.15
|
||||
steps: *steps
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo pip install flake8
|
||||
- run: ./bin/flake8_tests.sh
|
||||
backend-unit-tests:
|
||||
environment:
|
||||
COMPOSE_FILE: .circleci/docker-compose.circle.yml
|
||||
@@ -32,6 +38,9 @@ jobs:
|
||||
- run:
|
||||
name: Create Test Database
|
||||
command: docker-compose run --rm postgres psql -h postgres -U postgres -c "create database tests;"
|
||||
- run:
|
||||
name: List Enabled Query Runners
|
||||
command: docker-compose run --rm redash manage ds list_types
|
||||
- run:
|
||||
name: Run Tests
|
||||
command: docker-compose run --name tests redash tests --junitxml=junit.xml --cov-report xml --cov=redash --cov-config .coveragerc tests/
|
||||
@@ -39,26 +48,41 @@ jobs:
|
||||
name: Copy Test Results
|
||||
command: |
|
||||
mkdir -p /tmp/test-results/unit-tests
|
||||
docker cp tests:/app/coverage.xml ./coverage.xml
|
||||
docker cp tests:/app/coverage.xml ./coverage.xml
|
||||
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
|
||||
when: always
|
||||
- store_test_results:
|
||||
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 apt install python3-pip
|
||||
- run: sudo pip3 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,41 +96,29 @@ 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:
|
||||
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: .circleci/update_version
|
||||
- run: .circleci/docker_build
|
||||
build-docker-image: *build-docker-image-job
|
||||
build-preview-docker-image: *build-docker-image-job
|
||||
workflows:
|
||||
version: 2
|
||||
build:
|
||||
jobs:
|
||||
- python-flake8-tests
|
||||
- legacy-python-flake8-tests
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
- build-tarball:
|
||||
- backend-lint
|
||||
- backend-unit-tests:
|
||||
requires:
|
||||
- backend-lint
|
||||
- frontend-lint
|
||||
- frontend-unit-tests:
|
||||
requires:
|
||||
- backend-lint
|
||||
- frontend-lint
|
||||
- frontend-e2e-tests:
|
||||
requires:
|
||||
- frontend-lint
|
||||
- build-preview-docker-image:
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
@@ -115,15 +127,16 @@ workflows:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- hold:
|
||||
type: approval
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
- build-docker-image:
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- preview-image
|
||||
- /release\/.*/
|
||||
- hold
|
||||
|
||||
@@ -13,9 +13,17 @@ services:
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
worker:
|
||||
REDASH_RATELIMIT_ENABLED: "false"
|
||||
scheduler:
|
||||
build: ../
|
||||
command: scheduler
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
worker:
|
||||
build: ../
|
||||
command: worker
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
@@ -23,7 +31,18 @@ services:
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
QUEUES: "queries,scheduled_queries,celery,schemas"
|
||||
QUEUES: "default periodic schemas"
|
||||
celery_worker:
|
||||
build: ../
|
||||
command: celery_worker
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
PYTHONUNBUFFERED: 0
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
QUEUES: "queries,scheduled_queries"
|
||||
WORKERS_COUNT: 2
|
||||
cypress:
|
||||
build:
|
||||
@@ -31,13 +50,21 @@ services:
|
||||
dockerfile: .circleci/Dockerfile.cypress
|
||||
depends_on:
|
||||
- server
|
||||
- celery_worker
|
||||
- worker
|
||||
- scheduler
|
||||
environment:
|
||||
CYPRESS_baseUrl: "http://server:5000"
|
||||
PERCY_TOKEN: ${PERCY_TOKEN}
|
||||
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
|
||||
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
@@ -24,5 +24,5 @@ node_modules
|
||||
.sass-cache
|
||||
npm-debug.log
|
||||
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
client/cypress/screenshots
|
||||
client/cypress/videos
|
||||
|
||||
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).
|
||||
|
||||
36
Dockerfile
@@ -4,17 +4,47 @@ 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 python:3.7-slim
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# Controls whether to install extra dependencies needed for all data sources.
|
||||
ARG skip_ds_deps
|
||||
|
||||
RUN useradd --create-home redash
|
||||
|
||||
# Ubuntu packages
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
build-essential \
|
||||
pwgen \
|
||||
libffi-dev \
|
||||
sudo \
|
||||
git-core \
|
||||
wget \
|
||||
# Postgres client
|
||||
libpq-dev \
|
||||
# for SAML
|
||||
xmlsec1 \
|
||||
# Additional packages required for data sources:
|
||||
libssl-dev \
|
||||
default-libmysqlclient-dev \
|
||||
freetds-dev \
|
||||
libsasl2-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 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
@@ -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,117 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""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 pathlib 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))
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
worker() {
|
||||
celery_worker() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries}
|
||||
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() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-1}
|
||||
QUEUES=${QUEUES:-celery}
|
||||
SCHEDULE_DB=${SCHEDULE_DB:-celerybeat-schedule}
|
||||
echo "Starting RQ scheduler..."
|
||||
|
||||
echo "Starting scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
exec /app/manage.py rq scheduler
|
||||
}
|
||||
|
||||
exec /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
|
||||
dev_scheduler() {
|
||||
echo "Starting dev RQ scheduler..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq scheduler
|
||||
}
|
||||
|
||||
worker() {
|
||||
echo "Starting RQ worker..."
|
||||
|
||||
exec /app/manage.py rq worker $QUEUES
|
||||
}
|
||||
|
||||
dev_worker() {
|
||||
echo "Starting dev RQ worker..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq worker $QUEUES
|
||||
}
|
||||
|
||||
dev_celery_worker() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries}
|
||||
|
||||
echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- /usr/local/bin/celery worker --app=redash.worker -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() {
|
||||
@@ -31,6 +58,10 @@ celery_healthcheck() {
|
||||
exec /usr/local/bin/celery inspect ping --app=redash.worker -d celery@$HOSTNAME
|
||||
}
|
||||
|
||||
rq_healthcheck() {
|
||||
exec /app/manage.py rq healthcheck
|
||||
}
|
||||
|
||||
help() {
|
||||
echo "Redash Docker."
|
||||
echo ""
|
||||
@@ -38,9 +69,14 @@ help() {
|
||||
echo ""
|
||||
|
||||
echo "server -- start Redash server (with gunicorn)"
|
||||
echo "worker -- start Celery worker"
|
||||
echo "scheduler -- start Celery worker with a beat (scheduler) process"
|
||||
echo "celery_worker -- start Celery worker"
|
||||
echo "dev_celery_worker -- start Celery worker process which picks up code changes and reloads"
|
||||
echo "worker -- start a single RQ worker"
|
||||
echo "dev_worker -- start a single RQ worker with code reloading"
|
||||
echo "scheduler -- start an rq-scheduler instance"
|
||||
echo "dev_scheduler -- start an rq-scheduler instance with code reloading"
|
||||
echo "celery_healthcheck -- runs a Celery healthcheck. Useful for Docker's HEALTHCHECK mechanism."
|
||||
echo "rq_healthcheck -- runs a RQ healthcheck that verifies that all local workers are active. Useful for Docker's HEALTHCHECK mechanism."
|
||||
echo ""
|
||||
echo "shell -- open shell"
|
||||
echo "dev_server -- start Flask development server with debugger and auto reload"
|
||||
@@ -74,6 +110,30 @@ case "$1" in
|
||||
shift
|
||||
scheduler
|
||||
;;
|
||||
dev_scheduler)
|
||||
shift
|
||||
dev_scheduler
|
||||
;;
|
||||
celery_worker)
|
||||
shift
|
||||
celery_worker
|
||||
;;
|
||||
dev_celery_worker)
|
||||
shift
|
||||
dev_celery_worker
|
||||
;;
|
||||
dev_worker)
|
||||
shift
|
||||
dev_worker
|
||||
;;
|
||||
rq_healthcheck)
|
||||
shift
|
||||
rq_healthcheck
|
||||
;;
|
||||
celery_healthcheck)
|
||||
shift
|
||||
celery_healthcheck
|
||||
;;
|
||||
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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/env python
|
||||
from __future__ import print_function
|
||||
#!/bin/env python3
|
||||
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_change_log(previous_sha):
|
||||
args = ['git', '--no-pager', 'log', '--merges', '--grep', 'Merge pull request', '--pretty=format:"%h|%s|%b|%p"', 'master...{}'.format(previous_sha)]
|
||||
log = subprocess.check_output(args)
|
||||
@@ -33,4 +34,4 @@ if __name__ == '__main__':
|
||||
changes = get_change_log(previous_sha)
|
||||
|
||||
for change in changes:
|
||||
print(change)
|
||||
print(change)
|
||||
|
||||
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,4 +1,4 @@
|
||||
from __future__ import print_function
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
import urllib
|
||||
import argparse
|
||||
import os
|
||||
@@ -27,7 +27,7 @@ def run(cmd, cwd=None):
|
||||
|
||||
|
||||
def confirm(question):
|
||||
reply = str(raw_input(question + ' (y/n): ')).lower().strip()
|
||||
reply = str(input(question + ' (y/n): ')).lower().strip()
|
||||
|
||||
if reply[0] == 'y':
|
||||
return True
|
||||
|
||||
@@ -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, {
|
||||
ignoreUrls: true,
|
||||
ignoreComments: false,
|
||||
ignoreRegExpLiterals: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
}],
|
||||
"no-else-return": ["error", {"allowElseIf": true}],
|
||||
"object-curly-newline": ["error", {"consistent": true}],
|
||||
"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 }]
|
||||
}
|
||||
};
|
||||
|
||||
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
|
After Width: | Height: | Size: 5.0 KiB |
BIN
client/app/assets/images/db-logos/bigquery_gce.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/app/assets/images/db-logos/couchbase.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
client/app/assets/images/db-logos/dgraph.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
client/app/assets/images/db-logos/json.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
client/app/assets/images/db-logos/phoenix.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 9.3 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,14 +32,40 @@
|
||||
@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;
|
||||
@item-hover-bg: #e5f8ff;
|
||||
|
||||
.@{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 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.ant-select-dropdown-menu-item em {
|
||||
color: @input-color-placeholder;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
// Fix for disabled button styles inside Tooltip component.
|
||||
// Tooltip wraps disabled buttons with `<span>` and moves all styles
|
||||
// and classes to that `<span>`. This resets all button styles and
|
||||
@@ -54,11 +83,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 +156,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,21 +245,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
// styling for short modals (no lines)
|
||||
.@{dialog-prefix-cls}.shortModal {
|
||||
.@{dialog-prefix-cls} {
|
||||
&-header,
|
||||
&-footer {
|
||||
border: none;
|
||||
padding: 16px;
|
||||
.@{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;
|
||||
}
|
||||
}
|
||||
&-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +312,66 @@
|
||||
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 0 -8px;
|
||||
}
|
||||
|
||||
// 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%;
|
||||
}
|
||||
}
|
||||
|
||||
// for form items that contain text
|
||||
&.form-item-line-height-normal .@{form-prefix-cls}-item-control {
|
||||
line-height: 20px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,53 @@
|
||||
.alert {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
.alert-page h3 {
|
||||
flex-grow: 1;
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
input {
|
||||
margin: -0.2em 0;
|
||||
width: 100%;
|
||||
min-width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-dismissable,
|
||||
.alert-dismissible {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.alert-inverse {
|
||||
.alert-variant(@alert-inverse-bg; @alert-inverse-border; @alert-inverse-text);
|
||||
.btn-create-alert[disabled] {
|
||||
display: block;
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.alert-link {
|
||||
color: #fff !important;
|
||||
font-weight: normal !important;
|
||||
text-decoration: underline;
|
||||
.alert-state {
|
||||
border-bottom: 1px solid @input-border;
|
||||
padding-bottom: 30px;
|
||||
|
||||
.alert-state-indicator {
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.alert-last-triggered {
|
||||
color: @headings-color;
|
||||
}
|
||||
}
|
||||
|
||||
.growl-animated {
|
||||
&.alert-inverse {
|
||||
box-shadow: 0 0 5px fade(@alert-inverse-bg, 50%);
|
||||
}
|
||||
|
||||
&.alert-info {
|
||||
box-shadow: 0 0 5px fade(@alert-info-bg, 50%);
|
||||
}
|
||||
.alert-query-selector {
|
||||
min-width: 250px;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
&.alert-success {
|
||||
box-shadow: 0 0 5px fade(@alert-success-bg, 50%);
|
||||
}
|
||||
// allow form item labels to gracefully break line
|
||||
.alert-form-item label {
|
||||
white-space: initial;
|
||||
padding-right: 8px;
|
||||
line-height: 21px;
|
||||
|
||||
&.alert-warning {
|
||||
box-shadow: 0 0 5px fade(@alert-warning-bg, 50%);
|
||||
}
|
||||
|
||||
&.alert-danger {
|
||||
box-shadow: 0 0 5px fade(@alert-danger-bg, 50%);
|
||||
&::after {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-right: -15px;
|
||||
}
|
||||
@@ -19,6 +19,12 @@
|
||||
@font-size-base: 13px;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Borders
|
||||
-----------------------------------------------------------*/
|
||||
@border-color-split: #f0f0f0;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Typograpgy
|
||||
-----------------------------------------------------------*/
|
||||
@@ -30,6 +36,7 @@
|
||||
-----------------------------------------------------------*/
|
||||
@input-height-base: 35px;
|
||||
@input-color: #595959;
|
||||
@input-color-placeholder: #b4b4b4;
|
||||
@border-radius-base: 2px;
|
||||
@border-color-base: #E8E8E8;
|
||||
|
||||
@@ -72,3 +79,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,37 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: @header-height;
|
||||
padding-top: 0;
|
||||
background: #F6F8F9;
|
||||
font-family: @redash-font;
|
||||
position: relative;
|
||||
padding-bottom: @footer-height;
|
||||
|
||||
app-view {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
&.headless {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
.nav.app-header {
|
||||
display: none;
|
||||
app-view {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
div#footer {
|
||||
|
||||
.app-header-wrapper {
|
||||
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 +83,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 +130,154 @@ 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--sidebar {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-icon-danger {
|
||||
color: @red !important;
|
||||
}
|
||||
|
||||
// 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
|
||||
-----------------------------------------------------------*/
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
|
||||
.font-size(20, 8px, 8);
|
||||
|
||||
.f-inherit { font-size: inherit !important; }
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Font Weight
|
||||
@@ -153,4 +155,10 @@
|
||||
/* --------------------------------------------------------
|
||||
Border Radius
|
||||
-----------------------------------------------------------*/
|
||||
.brd-2 { border-radius: 2px; }
|
||||
.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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -225,4 +225,13 @@
|
||||
height: 37px;
|
||||
border-radius: 2px;
|
||||
width: 37px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Percy
|
||||
-----------------------------------------------------------*/
|
||||
@media only percy {
|
||||
.hide-in-percy, .pace {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
a.navbar-brand {
|
||||
padding: 5px 5px 0px 0px;
|
||||
}
|
||||
|
||||
.navbar .fa {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.navbar .collapse.in {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
a.navbar-brand img {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
#logout {
|
||||
color: white;
|
||||
position: relative;
|
||||
left: -9px;
|
||||
bottom: -11px;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
|
||||
|
||||
th.sortable-column {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
&:not(.table-striped) > thead > tr > th {
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
|
||||
[class*="bg-"] {
|
||||
& > tr > th {
|
||||
color: #fff;
|
||||
border-bottom: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
|
||||
& + tbody > tr:first-child > td {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
& > thead > tr > th {
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
@@ -29,24 +29,24 @@
|
||||
text-transform: uppercase;
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
|
||||
& > thead > tr,
|
||||
& > tbody > tr,
|
||||
& > tfoot > tr {
|
||||
|
||||
|
||||
& > th, & > td {
|
||||
|
||||
|
||||
&:first-child {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
|
||||
&:last-child {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tbody > tr:last-child > td {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
@@ -54,21 +54,21 @@
|
||||
|
||||
.table-bordered {
|
||||
border: 0;
|
||||
|
||||
|
||||
& > tbody > tr {
|
||||
& > td, & > th {
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
& > thead > tr > th {
|
||||
border-left: 0;
|
||||
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
@@ -86,14 +86,64 @@
|
||||
}
|
||||
|
||||
.tile .table {
|
||||
|
||||
|
||||
& > thead:not([class*="bg-"]) > tr > th {
|
||||
border-top: 1px solid @table-border-color;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.table-hover > tbody > tr:hover {
|
||||
background-color: #f4f4f4;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.table-data {
|
||||
tbody > tr > td {
|
||||
padding-top: 5px !important;
|
||||
}
|
||||
|
||||
.btn-favourite, .btn-archive {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
.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-visualization-container > 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,9 +43,7 @@
|
||||
@import 'inc/jumbotron';
|
||||
@import 'inc/profile';
|
||||
@import 'inc/404';
|
||||
@import 'inc/footer';
|
||||
@import 'inc/ie-warning';
|
||||
@import 'inc/navbar';
|
||||
@import 'inc/edit-in-place';
|
||||
@import 'inc/growl';
|
||||
@import 'inc/flex';
|
||||
@@ -56,12 +52,8 @@
|
||||
@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 +65,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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ body.fixed-layout {
|
||||
app-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@@ -92,7 +93,7 @@ edit-in-place p.editable:hover {
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_completion-highlight {
|
||||
@@ -172,6 +173,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 +209,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 +265,7 @@ a.label-tag {
|
||||
.query-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.query-fullscreen {
|
||||
@@ -336,7 +344,8 @@ a.label-tag {
|
||||
border-bottom: 1px solid #efefef;
|
||||
}
|
||||
|
||||
pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
.pivot-table-visualization-container > table,
|
||||
.visualization-renderer > .visualization-renderer-wrapper {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -551,6 +560,10 @@ nav .rg-bottom {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.edit-visualization {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
// Smaller screens
|
||||
|
||||
@media (max-width: 880px) {
|
||||
@@ -665,8 +678,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
<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,50 +1,52 @@
|
||||
|
||||
import { includes, startsWith, words, capitalize, clone, isNull } from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Form from 'antd/lib/form';
|
||||
import Checkbox from 'antd/lib/checkbox';
|
||||
import Button from 'antd/lib/button';
|
||||
import Select from 'antd/lib/select';
|
||||
import Input from 'antd/lib/input';
|
||||
import Divider from 'antd/lib/divider';
|
||||
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
|
||||
import { QuerySelector } from '@/components/QuerySelector';
|
||||
import { Query } from '@/services/query';
|
||||
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 Button from "antd/lib/button";
|
||||
import Select from "antd/lib/select";
|
||||
import Input from "antd/lib/input";
|
||||
import Divider from "antd/lib/divider";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import { QuerySelector } from "@/components/QuerySelector";
|
||||
import { Query } from "@/services/query";
|
||||
|
||||
const { Option } = Select;
|
||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||
|
||||
function getDefaultTitle(text) {
|
||||
return capitalize(words(text).join(' ')); // humanize
|
||||
}
|
||||
|
||||
function isTypeDate(type) {
|
||||
return startsWith(type, 'date') && !isTypeDateRange(type);
|
||||
return capitalize(words(text).join(" ")); // humanize
|
||||
}
|
||||
|
||||
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 = '';
|
||||
let helpText = "";
|
||||
let validateStatus = "";
|
||||
|
||||
if (!name) {
|
||||
helpText = 'Choose a keyword for this parameter';
|
||||
helpText = "Choose a keyword for this parameter";
|
||||
setValidation(false);
|
||||
} else if (includes(existingNames, name)) {
|
||||
helpText = 'Parameter with this name already exists';
|
||||
helpText = "Parameter with this name already exists";
|
||||
setValidation(false);
|
||||
validateStatus = 'error';
|
||||
validateStatus = "error";
|
||||
} else {
|
||||
if (isTypeDateRange(type)) {
|
||||
helpText = (
|
||||
<React.Fragment>
|
||||
Appears in query as {' '}
|
||||
<code style={{ display: 'inline-block', color: 'inherit' }}>
|
||||
Appears in query as{" "}
|
||||
<code style={{ display: "inline-block", color: "inherit" }}>
|
||||
{`{{${name}.start}} {{${name}.end}}`}
|
||||
</code>
|
||||
</React.Fragment>
|
||||
@@ -61,7 +63,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
||||
validateStatus={validateStatus}
|
||||
{...formItemProps}
|
||||
>
|
||||
<Input onChange={e => onChange(e.target.value)} autoFocus />
|
||||
<Input onChange={(e) => onChange(e.target.value)} autoFocus />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
@@ -98,12 +100,12 @@ function EditParameterSettingsDialog(props) {
|
||||
}
|
||||
|
||||
// title
|
||||
if (param.title === '') {
|
||||
if (param.title === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// query
|
||||
if (param.type === 'query' && !param.queryId) {
|
||||
if (param.type === "query" && !param.queryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -126,21 +128,29 @@ function EditParameterSettingsDialog(props) {
|
||||
return (
|
||||
<Modal
|
||||
{...props.dialog.props}
|
||||
title={isNew ? 'Add Parameter' : param.name}
|
||||
title={isNew ? "Add Parameter" : param.name}
|
||||
width={600}
|
||||
footer={[(
|
||||
<Button key="cancel" onClick={props.dialog.dismiss}>Cancel</Button>
|
||||
), (
|
||||
<Button key="submit" htmlType="submit" disabled={!isFulfilled()} type="primary" form="paramForm">
|
||||
{isNew ? 'Add Parameter' : 'OK'}
|
||||
</Button>
|
||||
)]}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={props.dialog.dismiss}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
htmlType="submit"
|
||||
disabled={!isFulfilled()}
|
||||
type="primary"
|
||||
form="paramForm"
|
||||
data-test="SaveParameterSettings"
|
||||
>
|
||||
{isNew ? "Add Parameter" : "OK"}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
|
||||
{isNew && (
|
||||
<NameInput
|
||||
name={param.name}
|
||||
onChange={name => setParam({ ...param, name })}
|
||||
onChange={(name) => setParam({ ...param, name })}
|
||||
setValidation={setIsNameValid}
|
||||
existingNames={props.existingParams}
|
||||
type={param.type}
|
||||
@@ -148,58 +158,144 @@ function EditParameterSettingsDialog(props) {
|
||||
)}
|
||||
<Form.Item label="Title" {...formItemProps}>
|
||||
<Input
|
||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
||||
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="datetime-with-seconds">Date and Time (with seconds)</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>
|
||||
<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}>
|
||||
{param.type === "enum" && (
|
||||
<Form.Item
|
||||
label="Values"
|
||||
help="Dropdown list values (newline delimited)"
|
||||
{...formItemProps}
|
||||
>
|
||||
<Input.TextArea
|
||||
data-test="EnumTextArea"
|
||||
rows={3}
|
||||
value={param.enumOptions}
|
||||
onChange={e => setParam({ ...param, enumOptions: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setParam({ ...param, enumOptions: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{param.type === 'query' && (
|
||||
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||
{param.type === "query" && (
|
||||
<Form.Item
|
||||
label="Query"
|
||||
help="Select query to load dropdown values from"
|
||||
{...formItemProps}
|
||||
>
|
||||
<QuerySelector
|
||||
selectedQuery={initialQuery}
|
||||
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
||||
onChange={(q) => setParam({ ...param, queryId: q && q.id })}
|
||||
type="select"
|
||||
/>
|
||||
</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="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
|
||||
{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
@@ -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;
|
||||
@@ -1,22 +1,49 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { currentUser, clientConfig } from '@/services/auth';
|
||||
import cx from 'classnames';
|
||||
import { clientConfig, currentUser } from '@/services/auth';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import Alert from 'antd/lib/alert';
|
||||
import HelpTrigger from '@/components/HelpTrigger';
|
||||
|
||||
export function EmailSettingsWarning({ featureName }) {
|
||||
return (clientConfig.mailSettingsMissing && currentUser.isAdmin) ? (
|
||||
<p className="alert alert-danger">
|
||||
{`It looks like your mail server isn't configured. Make sure to configure it for the ${featureName} to work.`}
|
||||
</p>
|
||||
) : null;
|
||||
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }) {
|
||||
if (!clientConfig.mailSettingsMissing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adminOnly && !currentUser.isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = (
|
||||
<span>
|
||||
Your mail server isn't configured correctly, and is needed for {featureName} to work.{' '}
|
||||
<HelpTrigger type="MAIL_CONFIG" className="f-inherit" />
|
||||
</span>
|
||||
);
|
||||
|
||||
if (mode === 'icon') {
|
||||
return (
|
||||
<Tooltip title={message}>
|
||||
<i className={cx('fa fa-exclamation-triangle', className)} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert message={message} type="error" className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
EmailSettingsWarning.propTypes = {
|
||||
featureName: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
mode: PropTypes.oneOf(['alert', 'icon']),
|
||||
adminOnly: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('emailSettingsWarning', react2angular(EmailSettingsWarning));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
EmailSettingsWarning.defaultProps = {
|
||||
className: null,
|
||||
mode: 'alert',
|
||||
adminOnly: false,
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
140
client/app/components/Filters.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { isArray, indexOf, get, map, includes, every, some, toNumber } 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 { formatColumnValue } from '@/filters';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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}>{formatColumnValue(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: formatColumnValue(value) })) :
|
||||
({ key: `${indexOf(filter.values, filter.current)}`, label: formatColumnValue(filter.current) })}
|
||||
allowClear={filter.multiple}
|
||||
optionFilterProp="children"
|
||||
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;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { react2angular } from 'react2angular';
|
||||
import React from 'react';
|
||||
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 +12,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 +27,79 @@ 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',
|
||||
],
|
||||
ALERT_SETUP: [
|
||||
'/user-guide/alerts/setting-up-an-alert',
|
||||
'Guide: Setting Up a New Alert',
|
||||
],
|
||||
MAIL_CONFIG: [
|
||||
'/open-source/setup/#Mail-Configuration',
|
||||
'Guide: Mail Configuration',
|
||||
],
|
||||
ALERT_NOTIF_TEMPLATE_GUIDE: [
|
||||
'/user-guide/alerts/custom-alert-notifications',
|
||||
'Guide: Custom Alerts Notifications',
|
||||
],
|
||||
FAVORITES: [
|
||||
'/user-guide/querying/favorites-tagging/#Favorites',
|
||||
'Guide: Favorites',
|
||||
],
|
||||
};
|
||||
|
||||
export class HelpTrigger extends React.Component {
|
||||
export default 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 +110,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 +130,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 +153,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 = () => {
|
||||
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 +185,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
|
||||
@@ -142,9 +239,3 @@ export class HelpTrigger extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('helpTrigger', react2angular(HelpTrigger));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
|
||||
import { isString, extend, each, map, includes, findIndex, find, fromPairs, clone, isEmpty } from 'lodash';
|
||||
import { isString, extend, each, has, map, includes, findIndex, find,
|
||||
fromPairs, clone, isEmpty } from 'lodash';
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
@@ -14,11 +15,10 @@ 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 { HelpTrigger } from '@/components/HelpTrigger';
|
||||
import { Parameter } from '@/services/parameters';
|
||||
import HelpTrigger from '@/components/HelpTrigger';
|
||||
|
||||
import './ParameterMappingInput.less';
|
||||
|
||||
@@ -120,8 +120,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 +127,6 @@ export class ParameterMappingInput extends React.Component {
|
||||
mapping: {},
|
||||
existingParamNames: [],
|
||||
onChange: () => {},
|
||||
clientConfig: null,
|
||||
Query: null,
|
||||
inputError: null,
|
||||
};
|
||||
|
||||
@@ -159,6 +155,17 @@ 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);
|
||||
}
|
||||
if (has(update, 'type')) {
|
||||
if (update.type === MappingType.StaticValue) {
|
||||
newMapping.value = newMapping.param.value;
|
||||
} else {
|
||||
newMapping.value = null;
|
||||
}
|
||||
}
|
||||
onChange(newMapping);
|
||||
};
|
||||
|
||||
@@ -169,7 +176,7 @@ export class ParameterMappingInput extends React.Component {
|
||||
value={this.props.mapping.type}
|
||||
onChange={e => this.updateSourceType(e.target.value)}
|
||||
>
|
||||
<Radio className="radio" value={MappingType.DashboardAddNew}>
|
||||
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
|
||||
New dashboard parameter
|
||||
</Radio>
|
||||
<Radio
|
||||
@@ -184,10 +191,10 @@ export class ParameterMappingInput extends React.Component {
|
||||
</Tooltip>
|
||||
) : null }
|
||||
</Radio>
|
||||
<Radio className="radio" value={MappingType.WidgetLevel}>
|
||||
<Radio className="radio" value={MappingType.WidgetLevel} data-test="WidgetParameterOption">
|
||||
Widget parameter
|
||||
</Radio>
|
||||
<Radio className="radio" value={MappingType.StaticValue}>
|
||||
<Radio className="radio" value={MappingType.StaticValue} data-test="StaticValueOption">
|
||||
Static value
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
@@ -228,9 +235,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -337,7 +343,7 @@ class MappingEditor extends React.Component {
|
||||
const { mapping, inputError } = this.state;
|
||||
|
||||
return (
|
||||
<div className="parameter-mapping-editor">
|
||||
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
||||
<header>
|
||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||
</header>
|
||||
@@ -345,8 +351,6 @@ class MappingEditor extends React.Component {
|
||||
mapping={mapping}
|
||||
existingParamNames={this.props.existingParamNames}
|
||||
onChange={this.onChange}
|
||||
clientConfig={clientConfig}
|
||||
Query={Query}
|
||||
inputError={inputError}
|
||||
/>
|
||||
<footer>
|
||||
@@ -358,15 +362,16 @@ class MappingEditor extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { visible, mapping } = this.state;
|
||||
return (
|
||||
<Popover
|
||||
placement="left"
|
||||
trigger="click"
|
||||
content={this.renderContent()}
|
||||
visible={this.state.visible}
|
||||
visible={visible}
|
||||
onVisibleChange={this.onVisibleChange}
|
||||
>
|
||||
<Button size="small" type="dashed">
|
||||
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
|
||||
<Icon type="edit" />
|
||||
</Button>
|
||||
</Popover>
|
||||
@@ -540,7 +545,13 @@ export class ParameterMappingListInput extends React.Component {
|
||||
param = param.clone().setValue(mapping.value);
|
||||
}
|
||||
|
||||
const value = Parameter.getValue(param);
|
||||
let value = Parameter.getExecutionValue(param);
|
||||
|
||||
// in case of dynamic value display the name instead of value
|
||||
if (param.hasDynamicValue) {
|
||||
value = param.normalizedValue.name;
|
||||
}
|
||||
|
||||
return this.getStringValue(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,194 +1,210 @@
|
||||
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 { QueryBasedParameterInput } from './QueryBasedParameterInput';
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Select from "antd/lib/select";
|
||||
import Input from "antd/lib/input";
|
||||
import InputNumber from "antd/lib/input-number";
|
||||
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
||||
import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
|
||||
import { isEqual, trim } 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
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
type: 'text',
|
||||
type: "text",
|
||||
value: null,
|
||||
enumOptions: '',
|
||||
enumOptions: "",
|
||||
queryId: null,
|
||||
parameter: null,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
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 = !isEqual(trim(value), trim(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 enumOptionsArray = enumOptions.split('\n').filter(v => v !== '');
|
||||
const { enumOptions, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
const enumOptionsArray = enumOptions.split("\n").filter((v) => v !== "");
|
||||
// Antd Select doesn't handle null in multiple mode
|
||||
const normalize = (val) =>
|
||||
parameter.multiValuesOptions && val === null ? [] : val;
|
||||
return (
|
||||
<Select
|
||||
className={this.props.className}
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
optionFilterProp="children"
|
||||
disabled={enumOptionsArray.length === 0}
|
||||
defaultValue={value}
|
||||
onChange={onSelect}
|
||||
value={normalize(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>))}
|
||||
{enumOptionsArray.map((option) => (
|
||||
<Option key={option} value={option}>
|
||||
{option}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
renderQueryBasedInput() {
|
||||
const { value, onSelect, queryId } = this.props;
|
||||
const { queryId, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<QueryBasedParameterInput
|
||||
className={this.props.className}
|
||||
mode={parameter.multiValuesOptions ? "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;
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
className={'form-control ' + className}
|
||||
defaultValue={!isNaN(value) && value || 0}
|
||||
onChange={onSelect}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(val) => this.onSelect(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 'enum': return this.renderEnumInput();
|
||||
case 'query': return this.renderQueryBasedInput();
|
||||
case 'number': return this.renderNumberInput();
|
||||
default: return this.renderTextInput();
|
||||
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}
|
||||
data-test="ParameterValueInput"
|
||||
>
|
||||
{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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
261
client/app/components/Parameters.jsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { size, filter, forEach, extend, get, includes } from "lodash";
|
||||
import { react2angular } from "react2angular";
|
||||
import {
|
||||
SortableContainer,
|
||||
SortableElement,
|
||||
DragHandle,
|
||||
} from "@/components/sortable";
|
||||
import { $location } from "@/services/ng";
|
||||
import { Parameter } from "@/services/parameters";
|
||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import Form from "antd/lib/form";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||
import { toHuman } from "@/filters";
|
||||
|
||||
import "./Parameters.less";
|
||||
|
||||
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,
|
||||
queryResultErrorData: PropTypes.shape({
|
||||
parameters: PropTypes.objectOf(PropTypes.string),
|
||||
}),
|
||||
unsavedParameters: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
parameters: [],
|
||||
editable: false,
|
||||
disableUrlUpdate: false,
|
||||
onValuesChange: () => {},
|
||||
onPendingValuesChange: () => {},
|
||||
onParametersEdit: () => {},
|
||||
queryResultErrorData: {},
|
||||
unsavedParameters: null,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { parameters } = props;
|
||||
this.state = {
|
||||
parameters,
|
||||
touched: {},
|
||||
};
|
||||
|
||||
if (!props.disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const { parameters, disableUrlUpdate, queryResultErrorData } = this.props;
|
||||
if (prevProps.parameters !== parameters) {
|
||||
this.setState({ parameters });
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
// reset touched flags on new error data
|
||||
if (prevProps.queryResultErrorData !== queryResultErrorData) {
|
||||
this.setState({ touched: {} });
|
||||
}
|
||||
};
|
||||
|
||||
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, touched }) => {
|
||||
if (isDirty) {
|
||||
param.setPendingValue(value);
|
||||
touched = { ...touched, [param.name]: true };
|
||||
} else {
|
||||
param.clearPendingValue();
|
||||
}
|
||||
onPendingValuesChange();
|
||||
return { parameters, touched };
|
||||
});
|
||||
};
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
applyChanges = () => {
|
||||
const { onValuesChange, disableUrlUpdate } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
const parametersWithPendingValues = parameters.filter(
|
||||
(p) => p.hasPendingValue
|
||||
);
|
||||
forEach(parameters, (p) => p.applyPendingValue());
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
onValuesChange(parametersWithPendingValues);
|
||||
return { parameters };
|
||||
});
|
||||
};
|
||||
|
||||
showParameterSettings = (parameter, index) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
EditParameterSettingsDialog.showModal({ parameter }).result.then(
|
||||
(updated) => {
|
||||
this.setState(({ parameters, touched }) => {
|
||||
touched = { ...touched, [parameter.name]: true };
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = Parameter.create(
|
||||
updatedParameter,
|
||||
updatedParameter.parentQueryId
|
||||
);
|
||||
onParametersEdit();
|
||||
return { parameters, touched };
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
getParameterFeedback = (param) => {
|
||||
// error msg
|
||||
const { queryResultErrorData } = this.props;
|
||||
const error = get(queryResultErrorData, ["parameters", param.name], false);
|
||||
if (error) {
|
||||
const feedback = <Tooltip title={error}>{error}</Tooltip>;
|
||||
return [feedback, "error"];
|
||||
}
|
||||
|
||||
// unsaved
|
||||
const { unsavedParameters } = this.props;
|
||||
if (includes(unsavedParameters, param.name)) {
|
||||
const feedback = (
|
||||
<>
|
||||
Unsaved{" "}
|
||||
<Tooltip title='Click the "Save" button to preserve this parameter.'>
|
||||
<i className="fa fa-question-circle" />
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
return [feedback, "warning"];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
renderParameter(param, index) {
|
||||
const { editable } = this.props;
|
||||
const touched = this.state.touched[param.name];
|
||||
const [feedback, status] = this.getParameterFeedback(param);
|
||||
|
||||
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>
|
||||
<Form.Item
|
||||
validateStatus={touched ? "" : status}
|
||||
help={feedback || null}
|
||||
>
|
||||
<ParameterValueInput
|
||||
type={param.type}
|
||||
value={param.normalizedValue}
|
||||
parameter={param}
|
||||
enumOptions={param.enumOptions}
|
||||
queryId={param.queryId}
|
||||
onSelect={(value, isDirty) =>
|
||||
this.setPendingValue(param, value, isDirty)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { parameters } = this.state;
|
||||
const { editable } = this.props;
|
||||
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
||||
return (
|
||||
<SortableContainer
|
||||
disabled={!editable}
|
||||
axis="xy"
|
||||
useDragHandle
|
||||
lockToContainerEdges
|
||||
helperClass="parameter-dragged"
|
||||
updateBeforeSortStart={this.onBeforeSortStart}
|
||||
onSortEnd={this.moveParameter}
|
||||
containerProps={{
|
||||
className: "parameter-container",
|
||||
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
|
||||
}}
|
||||
>
|
||||
{parameters.map((param, index) => (
|
||||
<SortableElement key={param.name} index={index}>
|
||||
<div className="parameter-block" data-editable={editable || null}>
|
||||
{editable && (
|
||||
<DragHandle data-test={`DragHandle-${param.name}`} />
|
||||
)}
|
||||
{this.renderParameter(param, index)}
|
||||
</div>
|
||||
</SortableElement>
|
||||
))}
|
||||
<ParameterApplyButton
|
||||
onClick={this.applyChanges}
|
||||
paramCount={dirtyParamCount}
|
||||
/>
|
||||
</SortableContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component("parameters", react2angular(Parameters));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
129
client/app/components/Parameters.less
Normal file
@@ -0,0 +1,129 @@
|
||||
@import '../assets/less/ant';
|
||||
|
||||
.parameter-block {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
padding: 0 12px 17px 0;
|
||||
vertical-align: top;
|
||||
z-index: 1;
|
||||
|
||||
.drag-handle {
|
||||
padding: 0 5px;
|
||||
margin-left: -5px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.parameter-container.sortable-container & {
|
||||
margin: 4px 0 0 4px;
|
||||
padding: 3px 6px 19px;
|
||||
}
|
||||
|
||||
&.parameter-dragged {
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-form-explain {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -20px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-form-item-control {
|
||||
line-height: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&.sortable-container {
|
||||
padding: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.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: 0 0 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, isArray, map, intersection, isEqual } 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: '',
|
||||
@@ -26,6 +29,7 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
options: [],
|
||||
value: null,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
@@ -34,31 +38,49 @@ 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);
|
||||
}
|
||||
if (this.props.value !== prevProps.value) {
|
||||
this.setValue(this.props.value);
|
||||
}
|
||||
}
|
||||
|
||||
_loadOptions(queryId) {
|
||||
setValue(value) {
|
||||
const { options } = this.state;
|
||||
if (this.props.mode === 'multiple') {
|
||||
value = isArray(value) ? value : [value];
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(value, optionValues);
|
||||
this.setState({ value: validValues });
|
||||
return validValues;
|
||||
}
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
value = found ? value : options[0].value;
|
||||
this.setState({ value });
|
||||
return value;
|
||||
}
|
||||
|
||||
async _loadOptions(queryId) {
|
||||
if (queryId && (queryId !== this.state.queryId)) {
|
||||
this.setState({ loading: true });
|
||||
Query.dropdownOptions({ id: queryId }, (options) => {
|
||||
if (this.props.queryId === queryId) {
|
||||
this.setState({ options, loading: false });
|
||||
const options = await this.props.parameter.loadDropdownValues();
|
||||
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
if (!found && isFunction(this.props.onSelect)) {
|
||||
this.props.onSelect(options[0].value);
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
this.setState({ options, loading: false }, () => {
|
||||
const updatedValue = this.setValue(this.props.value);
|
||||
if (!isEqual(updatedValue, this.props.value)) {
|
||||
this.props.onSelect(updatedValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, value, onSelect } = this.props;
|
||||
const { className, value, mode, onSelect, ...otherProps } = this.props;
|
||||
const { loading, options } = this.state;
|
||||
return (
|
||||
<span>
|
||||
@@ -66,10 +88,15 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
className={className}
|
||||
disabled={loading || (options.length === 0)}
|
||||
loading={loading}
|
||||
defaultValue={'' + value}
|
||||
mode={mode}
|
||||
value={this.state.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}
|
||||
|
||||
40
client/app/components/QueryLink.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { VisualizationType } from '@/visualizations';
|
||||
import { VisualizationName } from '@/visualizations/VisualizationName';
|
||||
|
||||
function QueryLink({ query, visualization, readOnly }) {
|
||||
const getUrl = () => {
|
||||
let hash = null;
|
||||
if (visualization) {
|
||||
if (visualization.type === 'TABLE') {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
hash = 'table';
|
||||
} else {
|
||||
hash = visualization.id;
|
||||
}
|
||||
}
|
||||
|
||||
return query.getUrl(false, hash);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={readOnly ? null : getUrl()} className="query-link">
|
||||
<VisualizationName visualization={visualization} />{' '}
|
||||
<span>{query.name}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
QueryLink.propTypes = {
|
||||
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
visualization: VisualizationType,
|
||||
readOnly: PropTypes.bool,
|
||||
};
|
||||
|
||||
QueryLink.defaultProps = {
|
||||
visualization: null,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export default QueryLink;
|
||||
@@ -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}
|
||||
{' '}
|
||||
@@ -146,11 +146,13 @@ export function QuerySelector(props) {
|
||||
notFoundContent={null}
|
||||
filterOption={false}
|
||||
defaultActiveFirstOption={false}
|
||||
className={props.className}
|
||||
data-test="QuerySelector"
|
||||
>
|
||||
{searchResults && searchResults.map((q) => {
|
||||
const disabled = q.is_draft;
|
||||
return (
|
||||
<Option value={q.id} key={q.id} disabled={disabled}>
|
||||
<Option value={q.id} key={q.id} disabled={disabled} className="query-selector-result" data-test={`QueryId${q.id}`}>
|
||||
{q.name}{' '}
|
||||
<QueryTagsControl isDraft={q.is_draft} tags={q.tags} className={cx('inline-tags-control', { disabled })} />
|
||||
</Option>
|
||||
@@ -161,7 +163,7 @@ export function QuerySelector(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span data-test="QuerySelector">
|
||||
{selectedQuery ? (
|
||||
<Input value={selectedQuery.name} suffix={clearIcon} readOnly />
|
||||
) : (
|
||||
@@ -175,7 +177,7 @@ export function QuerySelector(props) {
|
||||
<div className="scrollbox" style={{ maxHeight: '50vh', marginTop: 15 }}>
|
||||
{searchResults && renderResults()}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,12 +185,14 @@ QuerySelector.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
selectedQuery: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
type: PropTypes.oneOf(['select', 'default']),
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
QuerySelector.defaultProps = {
|
||||
selectedQuery: null,
|
||||
type: 'default',
|
||||
className: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { filter, debounce, find } from 'lodash';
|
||||
import { filter, debounce, find, isEmpty, size } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Input from 'antd/lib/input';
|
||||
import List from 'antd/lib/list';
|
||||
import Button from 'antd/lib/button';
|
||||
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 = {
|
||||
@@ -29,6 +30,9 @@ class SelectItemsDialog extends React.Component {
|
||||
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
|
||||
renderStagedItem: PropTypes.func,
|
||||
save: PropTypes.func, // (selectedItems[]) => Promise<any>
|
||||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
extraFooterContent: PropTypes.node,
|
||||
showCount: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -37,8 +41,11 @@ class SelectItemsDialog extends React.Component {
|
||||
selectedItemsTitle: 'Selected items',
|
||||
itemKey: item => item.id,
|
||||
renderItem: () => '',
|
||||
renderStagedItem: null, // use `renderItem` by default
|
||||
renderStagedItem: null, // hidden by default
|
||||
save: items => items,
|
||||
width: '80%',
|
||||
extraFooterContent: null,
|
||||
showCount: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
@@ -100,7 +107,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.');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -108,7 +115,7 @@ class SelectItemsDialog extends React.Component {
|
||||
renderItem(item, isStagedList) {
|
||||
const { renderItem, renderStagedItem } = this.props;
|
||||
const isSelected = this.isSelected(item);
|
||||
const render = isStagedList ? (renderStagedItem || renderItem) : renderItem;
|
||||
const render = isStagedList ? renderStagedItem : renderItem;
|
||||
|
||||
const { content, className, isDisabled } = render(item, { isSelected });
|
||||
|
||||
@@ -123,23 +130,29 @@ class SelectItemsDialog extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dialog, dialogTitle, inputPlaceholder, selectedItemsTitle } = this.props;
|
||||
const { dialog, dialogTitle, inputPlaceholder } = this.props;
|
||||
const { selectedItemsTitle, renderStagedItem, width, showCount } = this.props;
|
||||
const { loading, saveInProgress, items, selected } = this.state;
|
||||
const hasResults = items.length > 0;
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
width="80%"
|
||||
className="select-items-dialog"
|
||||
width={width}
|
||||
title={dialogTitle}
|
||||
okText="Save"
|
||||
okButtonProps={{
|
||||
loading: saveInProgress,
|
||||
disabled: selected.length === 0,
|
||||
}}
|
||||
onOk={() => this.save()}
|
||||
footer={(
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="flex-fill m-r-5" style={{ textAlign: 'left', color: 'rgba(0, 0, 0, 0.5)' }}>{this.props.extraFooterContent}</span>
|
||||
<Button onClick={dialog.dismiss}>Cancel</Button>
|
||||
<Button onClick={() => this.save()} loading={saveInProgress} disabled={selected.length === 0} type="primary">
|
||||
Save
|
||||
{showCount && !isEmpty(selected) ? ` (${size(selected)})` : null}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center m-b-10">
|
||||
<div className="w-50 m-r-10">
|
||||
<div className="flex-fill">
|
||||
<Input.Search
|
||||
defaultValue={this.state.searchTerm}
|
||||
onChange={event => this.search(event.target.value)}
|
||||
@@ -147,13 +160,15 @@ class SelectItemsDialog extends React.Component {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="w-50 m-l-10">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-stretch" style={{ minHeight: '30vh', maxHeight: '50vh' }}>
|
||||
<div className="w-50 m-r-10 scrollbox">
|
||||
<div className="flex-fill scrollbox">
|
||||
{loading && <LoadingState className="" />}
|
||||
{!loading && !hasResults && (
|
||||
<BigMessage icon="fa-search" message="No items match your search." className="" />
|
||||
@@ -166,15 +181,17 @@ class SelectItemsDialog extends React.Component {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-50 m-l-10 scrollbox">
|
||||
{(selected.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={selected}
|
||||
renderItem={item => this.renderItem(item, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20 scrollbox">
|
||||
{(selected.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={selected}
|
||||
renderItem={item => this.renderItem(item, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||