Compare commits
192 Commits
v5.0.2
...
v6.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee7537a6c | ||
|
|
9c12b04578 | ||
|
|
9579f12a83 | ||
|
|
463d4ce518 | ||
|
|
2e4d196452 | ||
|
|
4078af2996 | ||
|
|
73825ea266 | ||
|
|
b2a0d61844 | ||
|
|
1774edabc0 | ||
|
|
54b8e7c136 | ||
|
|
54f09f73db | ||
|
|
35aca1d4cf | ||
|
|
757333c2d6 | ||
|
|
92728de04c | ||
|
|
407f14ffca | ||
|
|
ecb8a5c244 | ||
|
|
0e8fab4872 | ||
|
|
c15fa0c592 | ||
|
|
09ab00e360 | ||
|
|
1728f924cf | ||
|
|
8dc10fbd9a | ||
|
|
a16170e701 | ||
|
|
07c0bba568 | ||
|
|
d36d18f85b | ||
|
|
bd20ce12ac | ||
|
|
1cdfcfaa3c | ||
|
|
2fdace518a | ||
|
|
3516e4ef45 | ||
|
|
d842968142 | ||
|
|
600741620a | ||
|
|
45f4277eb4 | ||
|
|
bcf3041c91 | ||
|
|
da423340ec | ||
|
|
4003d4f1aa | ||
|
|
a6b782e0ce | ||
|
|
5648de9ba8 | ||
|
|
13eb365f7b | ||
|
|
8257d9d037 | ||
|
|
babbeb79f0 | ||
|
|
8028397f27 | ||
|
|
e05c8e6060 | ||
|
|
fae2b70866 | ||
|
|
1119fce44c | ||
|
|
bfb7edc0eb | ||
|
|
a39a739473 | ||
|
|
c9dfac5b1d | ||
|
|
1b66fff3be | ||
|
|
0fe1b5f9d4 | ||
|
|
143db90a50 | ||
|
|
bac90db3ee | ||
|
|
649d46de89 | ||
|
|
0163e85eda | ||
|
|
f25beb3fb7 | ||
|
|
c66f63d7a5 | ||
|
|
16ae0aa3d8 | ||
|
|
68ada7b590 | ||
|
|
9e745ef648 | ||
|
|
ee0d7f5ec9 | ||
|
|
e36853ca84 | ||
|
|
d43b35ba6f | ||
|
|
6e4f0ccee8 | ||
|
|
0ce7772aa3 | ||
|
|
f6ef38479c | ||
|
|
bf85ddaaff | ||
|
|
8bb96c8c91 | ||
|
|
42b05cee00 | ||
|
|
d0fd02123a | ||
|
|
e34203dac3 | ||
|
|
c2bd8518a6 | ||
|
|
46363ccc70 | ||
|
|
5e1512e777 | ||
|
|
188c045fdb | ||
|
|
57d921dc2b | ||
|
|
df0804c8fd | ||
|
|
c289dde806 | ||
|
|
b7cadca3b7 | ||
|
|
43f8200707 | ||
|
|
a1b580bba6 | ||
|
|
19d0313ea2 | ||
|
|
667fe43e23 | ||
|
|
096eba3876 | ||
|
|
99115a12e6 | ||
|
|
7d601cbbc9 | ||
|
|
bf6a09c5aa | ||
|
|
99967e720f | ||
|
|
27f489de20 | ||
|
|
46941d3aa1 | ||
|
|
60c230add7 | ||
|
|
0784a0c6f5 | ||
|
|
9288d89248 | ||
|
|
391fbe130b | ||
|
|
e25c8c4145 | ||
|
|
57353d1b40 | ||
|
|
7f4e08154f | ||
|
|
500c82815b | ||
|
|
4a846f04e9 | ||
|
|
b1e9d87e2a | ||
|
|
ab6ed7da34 | ||
|
|
2e6883c527 | ||
|
|
4c44999b2c | ||
|
|
34c118cf83 | ||
|
|
38a89b9783 | ||
|
|
6e836795b2 | ||
|
|
719fc41dd1 | ||
|
|
467ec201da | ||
|
|
5ab143de41 | ||
|
|
284e497483 | ||
|
|
c5613dddf1 | ||
|
|
34fb3ac79f | ||
|
|
5f58c328f1 | ||
|
|
7d1dbb87db | ||
|
|
45f4d46245 | ||
|
|
44d05c35ac | ||
|
|
edd2cb85f7 | ||
|
|
6c364369bb | ||
|
|
869841b2ac | ||
|
|
c71f722552 | ||
|
|
af3a1e00c6 | ||
|
|
5b2ec81e65 | ||
|
|
0008e5803b | ||
|
|
e1c1f67abb | ||
|
|
30283235a4 | ||
|
|
845e33b396 | ||
|
|
17baa66188 | ||
|
|
5df7bd12c9 | ||
|
|
e14c8b61a0 | ||
|
|
a8a3ec66fd | ||
|
|
a4b9c2da12 | ||
|
|
e6146dae0f | ||
|
|
bd3fe880a4 | ||
|
|
02e919c39b | ||
|
|
99c73aef2d | ||
|
|
be377b5f59 | ||
|
|
6b11ae4312 | ||
|
|
9021977a54 | ||
|
|
9c8d06578a | ||
|
|
114beb2480 | ||
|
|
e97a5cbb29 | ||
|
|
e87efc8bc3 | ||
|
|
be7f601d21 | ||
|
|
9b59d10677 | ||
|
|
a40669e07f | ||
|
|
0bcf5d4be7 | ||
|
|
8bc96764a6 | ||
|
|
6ea03e58b4 | ||
|
|
94801665ab | ||
|
|
aa12151e19 | ||
|
|
c2429e92d2 | ||
|
|
5ffc85c066 | ||
|
|
fad757c878 | ||
|
|
3351a281ee | ||
|
|
1f0053f531 | ||
|
|
935dc38360 | ||
|
|
bfef7fae93 | ||
|
|
da6d456f6f | ||
|
|
c19199c2fb | ||
|
|
1e78861f85 | ||
|
|
10bc5a0bf6 | ||
|
|
313af904df | ||
|
|
8c478087a9 | ||
|
|
ccac41c6d4 | ||
|
|
69635f2c40 | ||
|
|
1867ea50bb | ||
|
|
c64d5ef6c0 | ||
|
|
e3a63899d3 | ||
|
|
4685887fe5 | ||
|
|
f103357e60 | ||
|
|
11738f73ac | ||
|
|
d07c4f969b | ||
|
|
505aafbce3 | ||
|
|
b765693879 | ||
|
|
4620fed0cf | ||
|
|
48ad1d2dce | ||
|
|
f2c323a089 | ||
|
|
ec17cc7eab | ||
|
|
6c7bbe9041 | ||
|
|
551b0222c4 | ||
|
|
2b0e6e9e79 | ||
|
|
4727c19253 | ||
|
|
2ff4d07e83 | ||
|
|
1997f53f40 | ||
|
|
c03b5d51b7 | ||
|
|
197665bb6a | ||
|
|
28fbc2ae62 | ||
|
|
ea1c4ca85c | ||
|
|
588e0cce43 | ||
|
|
8a50351520 | ||
|
|
34e39eda4a | ||
|
|
28a8525ce3 | ||
|
|
5e70f9c04a | ||
|
|
a05b5ba68d | ||
|
|
40ba66c58e |
@@ -1,6 +1,19 @@
|
||||
version: 2.0
|
||||
|
||||
flake8-steps: &steps
|
||||
- checkout
|
||||
- run: sudo pip install flake8
|
||||
- run: ./bin/flake8_tests.sh
|
||||
jobs:
|
||||
unit-tests:
|
||||
python-flake8-tests:
|
||||
docker:
|
||||
- image: circleci/python:3.7.0
|
||||
steps: *steps
|
||||
legacy-python-flake8-tests:
|
||||
docker:
|
||||
- image: circleci/python:2.7.15
|
||||
steps: *steps
|
||||
backend-unit-tests:
|
||||
environment:
|
||||
COMPOSE_FILE: .circleci/docker-compose.circle.yml
|
||||
COMPOSE_PROJECT_NAME: redash
|
||||
@@ -13,6 +26,7 @@ jobs:
|
||||
name: Build Docker Images
|
||||
command: |
|
||||
set -x
|
||||
docker-compose build --build-arg skip_ds_deps=true
|
||||
docker-compose up -d
|
||||
sleep 10
|
||||
- run:
|
||||
@@ -31,12 +45,43 @@ jobs:
|
||||
path: /tmp/test-results
|
||||
- store_artifacts:
|
||||
path: coverage.xml
|
||||
frontend-unit-tests:
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: npm install
|
||||
- run: npm run bundle
|
||||
- run: npm test
|
||||
frontend-e2e-tests:
|
||||
environment:
|
||||
COMPOSE_FILE: .circleci/docker-compose.cypress.yml
|
||||
COMPOSE_PROJECT_NAME: cypress
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- checkout
|
||||
- run:
|
||||
name: Install npm dependencies
|
||||
command: npm install
|
||||
- run:
|
||||
name: Setup Redash server
|
||||
command: |
|
||||
npm run cypress:server start-ci
|
||||
docker-compose run cypress node ./cypress/cypress-server.js setup
|
||||
- run:
|
||||
name: Execute Cypress tests
|
||||
command: docker-compose run cypress ./node_modules/.bin/cypress run
|
||||
build-tarball:
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: npm install
|
||||
- run: npm run bundle
|
||||
- run: npm run build
|
||||
- run: .circleci/update_version
|
||||
- run: .circleci/pack
|
||||
@@ -52,74 +97,31 @@ jobs:
|
||||
- run: docker login -u $DOCKER_USER -p $DOCKER_PASS
|
||||
- run: docker build -t redash/redash:$(.circleci/docker_tag) .
|
||||
- run: docker push redash/redash:$(.circleci/docker_tag)
|
||||
integration-tests:
|
||||
working_directory: ~/redash
|
||||
machine: true
|
||||
environment:
|
||||
REDASH_SERVER_URL : "http://127.0.0.1:5000/"
|
||||
DOCKER_IMAGE: mozilla/redash-ui-tests
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Install Docker Compose
|
||||
command: |
|
||||
set -x
|
||||
pip install --upgrade pip
|
||||
pip install docker-compose>=1.18
|
||||
docker-compose --version
|
||||
- run:
|
||||
name: Pull redash images
|
||||
command: |
|
||||
set -x
|
||||
docker-compose -f docker-compose.yml up --no-start
|
||||
sleep 10
|
||||
- run:
|
||||
name: Pull redash-ui-tests
|
||||
command: docker pull "${DOCKER_IMAGE}":latest
|
||||
- run:
|
||||
name: Setup redash instance
|
||||
command: |
|
||||
set -x
|
||||
docker-compose run --rm --user root server create_db
|
||||
docker-compose run --rm postgres psql -h postgres -U postgres -c "create database tests"
|
||||
docker-compose run --rm --user root server /app/manage.py users create_root root@example.com "rootuser" --password "IAMROOT" --org default
|
||||
docker-compose run --rm --user root server /app/manage.py ds new "ui-tests" --type "url" --options '{"title": "uitests"}'
|
||||
docker-compose run -d -p 5000:5000 --user root server
|
||||
docker-compose start postgres
|
||||
docker-compose run --rm --user root server npm install
|
||||
docker-compose run --rm --user root server npm run build
|
||||
- run:
|
||||
name: Run tests
|
||||
command: |
|
||||
set -x
|
||||
docker run --net="host" --env REDASH_SERVER_URL="${REDASH_SERVER_URL}" "${DOCKER_IMAGE}"
|
||||
- store_artifacts:
|
||||
path: report.html
|
||||
workflows:
|
||||
version: 2
|
||||
integration_tests:
|
||||
jobs:
|
||||
- integration-tests:
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
build:
|
||||
jobs:
|
||||
- unit-tests
|
||||
- python-flake8-tests
|
||||
- legacy-python-flake8-tests
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
- build-tarball:
|
||||
requires:
|
||||
- unit-tests
|
||||
- backend-unit-tests
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9\-a-z]+)*/
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- release/.*
|
||||
- /release\/.*/
|
||||
- build-docker-image:
|
||||
requires:
|
||||
- unit-tests
|
||||
- backend-unit-tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- release/.*
|
||||
- master
|
||||
- preview-build
|
||||
- /release\/.*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '2'
|
||||
version: '3'
|
||||
services:
|
||||
redash:
|
||||
build: ../
|
||||
|
||||
43
.circleci/docker-compose.cypress.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: '3'
|
||||
services:
|
||||
server:
|
||||
build: ../
|
||||
command: dev_server
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
PYTHONUNBUFFERED: 0
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
worker:
|
||||
build: ../
|
||||
command: scheduler
|
||||
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,celery"
|
||||
WORKERS_COUNT: 2
|
||||
cypress:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: Dockerfile.cypress
|
||||
depends_on:
|
||||
- server
|
||||
- worker
|
||||
environment:
|
||||
CYPRESS_baseUrl: "http://server:5000"
|
||||
redis:
|
||||
image: redis:3.0-alpine
|
||||
restart: unless-stopped
|
||||
postgres:
|
||||
image: postgres:9.5.6-alpine
|
||||
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
|
||||
restart: unless-stopped
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/bin/bash
|
||||
VERSION=$(jq -r .version package.json)
|
||||
FULL_VERSION=$VERSION.b$CIRCLE_BUILD_NUM
|
||||
if [ $CIRCLE_BRANCH = master ] || [ $CIRCLE_BRANCH = preview-build ]
|
||||
then
|
||||
FULL_VERSION='preview'
|
||||
else
|
||||
VERSION=$(jq -r .version package.json)
|
||||
FULL_VERSION=$VERSION.b$CIRCLE_BUILD_NUM
|
||||
fi
|
||||
|
||||
echo $FULL_VERSION
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
VERSION=$(jq -r .version package.json)
|
||||
FULL_VERSION=$VERSION+b$CIRCLE_BUILD_NUM
|
||||
|
||||
sed -ri "s/^__version__ = '([A-Za-z0-9.-]*)'/__version__ = '$FULL_VERSION'/" redash/__init__.py
|
||||
sed -ri "s/^__version__ = '([A-Za-z0-9.-]*)'/__version__ = '$FULL_VERSION'/" redash/__init__.py
|
||||
sed -i "s/dev/$CIRCLE_SHA1/" client/app/version.json
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
client/.tmp/
|
||||
client/dist/
|
||||
node_modules/
|
||||
.tmp/
|
||||
.venv/
|
||||
.git/
|
||||
|
||||
2
.github/ISSUE_TEMPLATE.md
vendored
@@ -8,7 +8,7 @@
|
||||
#
|
||||
#####################################################################
|
||||
|
||||
**Got an idea for a new feature?** Check if it isn't on the roadmap already: http://bit.ly/redash-roadmap and start a new discussion in the features category: https://discuss.redash.io/c/feature-requests 🌟.
|
||||
**Got an idea for a new feature?** Check if it isn't on the roadmap already: https://bit.ly/redash-roadmap and start a new discussion in the features category: https://discuss.redash.io/c/feature-requests 🌟.
|
||||
|
||||
Found a bug? Please fill out the sections below... thank you 👍
|
||||
|
||||
|
||||
2
.gitignore
vendored
@@ -24,3 +24,5 @@ node_modules
|
||||
.sass-cache
|
||||
npm-debug.log
|
||||
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
|
||||
198
CHANGELOG.md
@@ -1,5 +1,203 @@
|
||||
# Change Log
|
||||
|
||||
## v6.0.0-beta - 2018-12-03
|
||||
|
||||
This release was 2 months in the making and it is full with good stuff!
|
||||
|
||||
* We have 5 new data sources: Databricks, IBM DB2, Kylin, Druid and Rockset. ⌗
|
||||
* There are fixes and improvements to 11 existing data sources (MySQL, Redshift, Postgres, MongoDB, Google BigQuery, Vertica, TreasureData, Presto, ClickHouse, Google Sheets and Google Analytics).
|
||||
* The Query Results data source can now load cached results, just use the `cached_query_` prefix instead of `query_`.
|
||||
* On the visualizations front we added a Heatmap visualization and did updated the table and counter visualizations.
|
||||
* Alerts got some fixes and a new destination: PagerDuty.
|
||||
* If the live autocomplete in the code editor annoys you, you can disable it now (although we're working to make it better, see #3092).
|
||||
* Fast queries will now load faster. 🏃♂️
|
||||
* We improved the layout of visualizations and content on smaller screen sizes. 📱
|
||||
* For those of you who like sharing, you can now enable the ability to share ownership of queries and dashboards and let others to edit them. Check the Settings page to enable this feature.
|
||||
|
||||
There were also important changes to the code and infrastructure:
|
||||
|
||||
* More components moved to React.
|
||||
* We switched to Webpack 4 with the help of @dmonego.
|
||||
* We upgraded to Celery 4 with the help of @emtwo, @jezdez, @mashrikt and @atharvai.
|
||||
* We started moving towards Python 3 for our backend. The first step was to make sure our code pass basic sanity tests with Flake 8, which was implemented by @cclauss.
|
||||
* We improved our testing on the frontend by adding setup for Jest tests and E2E testing using Cypress (@gabrieldutra).
|
||||
* Each pull request now gets a deploy preview using Netlify to easily test frontend changes.
|
||||
|
||||
This is just a summary, you're welcome to review the full list below. ⬇
|
||||
|
||||
This release had contributions from 38 people: @arikfr, @kravets-levko, @jezdez, @kyoshidajp, @kocsmy, @alison985, @gabrieldutra, @washort, @GitSumito, @emtwo, @rauchy, @alexanderlz, @denisov-vlad, @ariarijp, @yoavbls, @zhujunsan, @sjakthol, @koooge, @SakuradaJun, @dmonego, @Udomomo, @cclauss, @combineads, @zaimy, @Trigl, @ralphilius, @jodevsa, @deecay, @igorcanadi, @pashaxp, @hoangphuoc25, @toph, @burnash, @wankdanker, @Yossi-a, @Rovel, @kadrach, and @nicof38. Thank you, everyone 🙏
|
||||
|
||||
### Added
|
||||
|
||||
* #2747, #3143 Add a new Databricks query runner. @alison985, @jezdez, @arikfr
|
||||
* #2767 Add ability to add viz to dashboard from query edit page. @alison985, @jezdez
|
||||
* #2780 Add a query autocomplete toggle. @alison985, @jezdez, @arikfr
|
||||
* #2768 Add authentication via JWT providers. @SakuradaJun
|
||||
* #2790 Add the ability to sort favorited queries, paginate the dashboard list and improve UI inconsistencies. @jezdez
|
||||
* #2681 Add ability to search table column names in schema browser. @alison985
|
||||
* #2855 Add option to query cached results. @yoavbls
|
||||
* #2740 Add ability for extensions to add periodic tasks. @emtwo
|
||||
* #2924 Google Spreadsheets: Add support for opening by URL. @alexanderlz
|
||||
* #2903 Add PagerDuty as an Alert Destination. @alexanderlz
|
||||
* #2824 Add support for expanding dashboard visualizations. @sjakthol
|
||||
* #2900 Add ability to specify a counter label. @ralphilius
|
||||
* #2565 Add frontend extension capabilities. @emtwo
|
||||
* #2848 Add IBM Db2 as a data source using the ibm-db Python package. @nicof38
|
||||
* #2959 Add option to auto reload widget data in shared dashboards. @arikfr
|
||||
* #2993 Add page size settings. @kyoshidajp
|
||||
* #2080 New Heatmap chart visualization with Plotly. @deecay
|
||||
* #2991 Show users in CLI group list. @GitSumito
|
||||
* #2342 New SQLPARSE_FORMAT_OPTIONS setting to configure query formatter. @ariarijp
|
||||
* #3031 Add some tests for Query Results. @ariarijp
|
||||
* #2936 Add Kylin data source. @Trigl
|
||||
* #3047 Add Druid data source. @rauchy
|
||||
* #3077 New user interface for the feature flag of the share edit permissions feature. @arikfr
|
||||
* #3007 Add permissions to the result of "manage.py groups list" command. @Udomomo
|
||||
* #3088 Add get_current_user() fuction for the Python query runner. @kyoshidajp
|
||||
* #3114 Add event tracking to autocomplete toggle. @arikfr
|
||||
* #3068 Add Rockset query runner. @igorcanadi, @arikfr
|
||||
* #3105 Display frontend version. @rauchy
|
||||
|
||||
### Changed
|
||||
|
||||
* #2636 Rewrite query editor with React. @washort, @arikfr
|
||||
* #2637 Convert edit-in-place component to React. @washort, @arikfr
|
||||
* #2766 Suitable events are now being recorded server side instead of in the frontend. @alison985, @jezdez
|
||||
* #2796 Change placement (right/bottom) of chart legend depending on chart width. @kravets-levko
|
||||
* #2833 Uses server side sort order for tag list and show count of tagged items. @jezdez
|
||||
* #2318 Support authentication for the URL data source. @jezdez
|
||||
* #2884 Rename Yandex Metrika to Metrica. @jezdez
|
||||
* #2909 MySQL: hide sys tables. @arikfr
|
||||
* #2817 Consistently use simplejson for loading and dumping JSON. @jezdez
|
||||
* #2872 Use Plotly's function to clean y-values (x may be category or date/time). @kravets-levko
|
||||
* #2938 Auto focus tag input. @kyoshidajp
|
||||
* #2927 Design refinements for queries pages. @kocsmy
|
||||
* #2950 Show activity status in CLI user list. @GitSumito
|
||||
* #2968 Presto data source: setting protocol (http/https), safe loading of error messages. @arikfr
|
||||
* #2967 Show groups in CLI user list. @GitSumito
|
||||
* #2603 MongoDB: Update requirements to support srv. @arikfr
|
||||
* #2961 MongoDB: Skip system collections when loading schema. @arikfr
|
||||
* #2960 Add timeout to various HTTP requests. @arikfr
|
||||
* #2983 Databricks: New logo, updated name and enabled by default. @arikfr
|
||||
* #2982 Table visualization: change default size to 25 and add more size options. @arikfr
|
||||
* #2866 Redshift: Hide tables the configured user cannot access. @sjakthol
|
||||
* #3058 Mustache: don't html-escape query parameters values. @kravets-levko
|
||||
* #3079 Always use basic autocomplete, as well as the live autocomplete. @arikfr
|
||||
* #3084 Support tel://, sms://, mailto:// links in query results. @zhujunsan
|
||||
* #3083 Clickhouse: Add WITH TOTALS option support. @denisov-vlad
|
||||
* #3063 Allow setting colors for bubble charts. @toph
|
||||
* #3085 BigQuery: Switch to Standard SQL as the default. @kyoshidajp
|
||||
* #3094 Tags autocomplete: Show note when creating a new label. @kravets-levko
|
||||
* #2984 Autocomplete toggle improvements. @arikfr
|
||||
* #3089 Open new tab when forking a query. @kyoshidajp
|
||||
* #3126 MongoDB: add support for sorting columns. @arikfr
|
||||
* #3128 Improve backoff algorithm of query results polling to speed it up. @arikfr
|
||||
* #3125 Vertica: update driver & add support for connection timeout. @arikfr
|
||||
* #3124 Support unicode in Postgres/Redshift schema. @arikfr
|
||||
* #3138 Migrate all tags components to React. @kravets-levko
|
||||
* #3139 Better manage permissions modal. @kocsmy
|
||||
* #3149 Improve tag link colors and fix group tags on Users page. @kocsmy
|
||||
* #3146 Update, replace and fix new alert destination logos so it fits better. @kocsmy
|
||||
* #3147 Add and improve recent db logos that didn't fit in size properly. @kocsmy
|
||||
* #3148 Fix label positioning on no found screen. @kocsmy
|
||||
* #3156 json_dumps: add support for serializing buffer objects. @arikfr
|
||||
|
||||
### Fixed
|
||||
|
||||
* #2849 Fix invalid reference to alert.to_dict() in webhook. @wankdanker
|
||||
* #2840 Improve counter visualization text scaling. @kravets-levko
|
||||
* #2854 Widget titles are no longer rendered wrong on public dashboards. @kravets-levko
|
||||
* #2318 Removed redundant exception handling in data sources since that's handled in the query backend. @jezdez
|
||||
* #2886 Fix Javascript build that broke because registerAll tried to run EditInPlace component. @arikfr
|
||||
* #2911 Don’t show “Add to dashboard” in dropdown to unsaved queries. @jezdez
|
||||
* #2916 Fix export query results output file name. @gabrieldutra
|
||||
* #2917 Fix output file name not changing after rename query. @gabrieldutra
|
||||
* #2868 Address edge case when retrieving Glue schemas for Athena data source. @kadrach
|
||||
* #2929 Fix: date value in a filter is duplicated. @combineads
|
||||
* #2875 Unbreak charts with long legend break in horizontal mode. Update plotly.js. @kravets-levko
|
||||
* #2937 Fix event recording in admin API backend. @kyoshidajp
|
||||
* #2953 Minor fixes for the Clickhouse data source. @denisov-vlad
|
||||
* #2941 Bring back fix to Box plot hover. @arikfr
|
||||
* #2957 Apply missing CSS classes to EditInPlace component. @arikfr
|
||||
* #2897 Show "Add description" only after saving the query. @arikfr
|
||||
* #2922 Query page layout improvements for small screens. @kravets-levko
|
||||
* #2956 Clickhouse: move timeout to params. @denisov-vlad
|
||||
* #2964 Fix no tags shown when having empty set. @gabrieldutra
|
||||
* #2757 Use full text search ranking when searching in list views. @jezdez
|
||||
* #2969 Query Results data source: improved errors, quoted column names. @arikfr
|
||||
* #2906 Preventing open redirection in loging process. @kyoshidajp
|
||||
* #2867 TreasureData: Deduplicate column names. @zaimy
|
||||
* #2994 Fix scheme of various URLs from http to https. @kyoshidajp
|
||||
* #2992 Fix an invalid prop type warning in new version notifier. @kyoshidajp
|
||||
* #3022 Fix Toolbox covering part of a chart. @kravets-levko
|
||||
* #2998 Fix charts losing responsive features after refreshing the dashboard. @kravets-levko
|
||||
* #3034 Postgres: handle NaN/Infinity values. @kravets-levko
|
||||
* #2745 Sort columns with undefined values. @Yossi-a
|
||||
* #3041 Sort CLI output of lists. @GitSumito
|
||||
* #2803, #3006 Address various tag display issues on query list page. @kocsmy, @alison985
|
||||
* #3049 Fix edit-in-place component which ignored isEditable flag and didn't work on Groups page. @kravets-levko
|
||||
* #2965 Google Analytics: Fix crash when no results are returned. @alexanderlz
|
||||
* #3061 Fix table visualization so that the horizontal scrollbar is not be always visible. @kravets-levko
|
||||
* #3076 Add white-space padding to separators in the footer. @burnash
|
||||
* #2919 Fix URL data source to not require a URL. @arikfr
|
||||
* #3098 Force AngularJS to update query editor properly. @washort
|
||||
* #3100 Delete redundant regex segment in query result frontend. @zhujunsan
|
||||
* #2978 Prevent the query update timestamp from changing when it is linked to new query results. @rauchy
|
||||
* #3046 Fix query page header. @kravets-levko
|
||||
* #3097 Mongo: Fix collection fields retreival bug when Views are present. @jodevsa
|
||||
* #3107 Keep query text in local state for now. @washort
|
||||
* #3111 Fix mobile padding issues on Query results. @kocsmy
|
||||
* #3122 Show menu divider only if query is archived. @jezdez
|
||||
* #3120 Fix tag counts for dashboards and queries. @jezdez
|
||||
* #3141 Fix schema refresh to work on MySQL 8. @hoangphuoc25
|
||||
* #3142 Fix: editing dashboard title results in the visualizations being replaced by the loading markers. @kravets-levko
|
||||
|
||||
### Other
|
||||
|
||||
* #2850 The setup scripts are now based on Ubuntu 18.04 LTS and Docker. @pashaxp, @arikfr
|
||||
* #2985 Add Jest based tests to our stack. @arikfr
|
||||
* #2999 Add netlify configuration. @arikfr
|
||||
* #3000 Initial Cypress based E2E test infrastructure. @gabrieldutra
|
||||
* #2898 Move Ant styles into a central location. @arikfr
|
||||
* #2910 Fix webpack build error about BigMessage. @jezdez
|
||||
* #2928 Speed up builds by skipping installing requirements_all_ds.txt in CI unit tests. @arikfr
|
||||
* #2963 Fix tarball build failure. @emtwo
|
||||
* #2996 Fix setup.sh failures when run as root. @arikfr
|
||||
* #2989 Rearrange make targets. @koooge
|
||||
* #3036 Update Flask-Admin to 1.5.2. @yoavbls
|
||||
* #2901 Fix documentation links. @kravets-levko
|
||||
* #3073 Remove only Redash containers in clean Make task. @ariarijp
|
||||
* #3048 Remove pytest-watch dependency to workaround an issue with watchdog. @rauchy
|
||||
* #2905 Update development docker-compose.yml file to use latest Redis and Postgres servers and specify working volume explictly. @Rovel
|
||||
* #3032 Makefile: Add make targets for test. @koooge
|
||||
* #2933 Switch to Webpack 4. @dmonego
|
||||
* #2908 Update setup files. @arikfr
|
||||
* #2946 Update snowflake_connector_python version. @arikfr
|
||||
* #2773 Upgrade to Celery 4.2.1. @emtwo, @jezdez
|
||||
* #2881 CircleCI: Make flake8 tests pass on Legacy Python and Python 3. @cclauss
|
||||
* #2907 Remove unused dependencies (honcho, wsgiref). @arikfr
|
||||
* #3039 Build docker image on master branch. @arikfr
|
||||
* #3106 Fix registerAll failures after minification. @arikfr
|
||||
|
||||
|
||||
## v5.0.2 - 2018-10-18
|
||||
|
||||
### Security
|
||||
|
||||
* Fix: prevent Open Redirect vulnerability.
|
||||
|
||||
|
||||
## v5.0.1 - 2018-09-27
|
||||
|
||||
### Added
|
||||
|
||||
* Added support for JWT authentication (for services like Cloudflare Access or Google IAP).
|
||||
|
||||
### Changed
|
||||
|
||||
* Upgraded Celery version to 3.1.26 to make upgrade to Celery 4 easier.
|
||||
|
||||
|
||||
## v5.0.0 - 2018-09-21
|
||||
|
||||
Final release for V5. Most of the changes were already in the beta release of V5, but this includes several fixes along
|
||||
|
||||
@@ -9,7 +9,7 @@ The following is a set of guidelines for contributing to Redash. These are guide
|
||||
- [Feature Roadmap](https://trello.com/b/b2LUHU7A/redash-roadmap)
|
||||
- [Feature Requests](https://discuss.redash.io/c/feature-requests)
|
||||
- [Documentation](https://redash.io/help/)
|
||||
- [Blog](http://blog.redash.io/)
|
||||
- [Blog](https://blog.redash.io/)
|
||||
- [Twitter](https://twitter.com/getredash)
|
||||
|
||||
---
|
||||
@@ -67,7 +67,7 @@ The project's documentation can be found at [https://redash.io/help/](https://re
|
||||
|
||||
### Release Method
|
||||
|
||||
We publish a stable release every ~2 months, although the goal is to get to a stable release every month. You can see the change log on [GitHub releases page](http://github.com/getredash/redash/releases).
|
||||
We publish a stable release every ~2 months, although the goal is to get to a stable release every month. You can see the change log on [GitHub releases page](https://github.com/getredash/redash/releases).
|
||||
|
||||
Every build of the master branch updates the latest *RC release*. These releases are usually stable, but might contain regressions and therefore recommended for "advanced users" only.
|
||||
|
||||
@@ -75,4 +75,4 @@ When we release a new stable release, we also update the *latest* Docker image t
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project adheres to the Contributor Covenant [code of conduct](http://redash.io/community/code_of_conduct). By participating, you are expected to uphold this code. Please report unacceptable behavior to team@redash.io.
|
||||
This project adheres to the Contributor Covenant [code of conduct](https://redash.io/community/code_of_conduct). By participating, you are expected to uphold this code. Please report unacceptable behavior to team@redash.io.
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
FROM redash/base:latest
|
||||
|
||||
# Controls whether to install extra dependencies needed for all data sources.
|
||||
ARG skip_ds_deps
|
||||
|
||||
# We first copy only the requirements file, to avoid rebuilding on every file
|
||||
# change.
|
||||
COPY requirements.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
RUN pip install -r requirements.txt -r requirements_dev.txt -r 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
|
||||
|
||||
COPY . ./
|
||||
RUN npm install && npm run build && rm -rf node_modules
|
||||
RUN npm install && npm run bundle && npm run build && rm -rf node_modules
|
||||
RUN chown -R redash /app
|
||||
USER redash
|
||||
|
||||
|
||||
10
Dockerfile.cypress
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM cypress/browsers:chrome67
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN npm install cypress > /dev/null
|
||||
|
||||
COPY cypress /usr/src/app/cypress
|
||||
COPY cypress.json /usr/src/app/cypress.json
|
||||
|
||||
RUN ./node_modules/.bin/cypress verify
|
||||
51
Makefile
Normal file
@@ -0,0 +1,51 @@
|
||||
.PHONY: compose_build up test_db create_database clean down bundle tests lint backend-unit-tests frontend-unit-tests test build watch start
|
||||
|
||||
compose_build:
|
||||
docker-compose build
|
||||
|
||||
up:
|
||||
docker-compose up -d --build
|
||||
|
||||
test_db:
|
||||
@for i in `seq 1 5`; do \
|
||||
if (docker-compose exec postgres sh -c 'psql -U postgres -c "select 1;"' 2>&1 > /dev/null) then break; \
|
||||
else echo "postgres initializing..."; sleep 5; fi \
|
||||
done
|
||||
docker-compose exec postgres sh -c 'psql -U postgres -c "drop database if exists tests;" && psql -U postgres -c "create database tests;"'
|
||||
|
||||
create_database:
|
||||
docker-compose run server create_db
|
||||
|
||||
clean:
|
||||
docker-compose down && docker-compose rm
|
||||
|
||||
down:
|
||||
docker-compose down
|
||||
|
||||
bundle:
|
||||
docker-compose run server bin/bundle-extensions
|
||||
|
||||
tests:
|
||||
docker-compose run server tests
|
||||
|
||||
lint:
|
||||
./bin/flake8_tests.sh
|
||||
|
||||
backend-unit-tests: up test_db
|
||||
docker-compose run --rm --name tests server tests
|
||||
|
||||
frontend-unit-tests: bundle
|
||||
npm install
|
||||
npm run bundle
|
||||
npm test
|
||||
|
||||
test: lint backend-unit-tests frontend-unit-tests
|
||||
|
||||
build: bundle
|
||||
npm run build
|
||||
|
||||
watch: bundle
|
||||
npm run watch
|
||||
|
||||
start: bundle
|
||||
npm run start
|
||||
@@ -16,7 +16,7 @@ Today **_Redash_** has support for querying multiple databases, including: Redsh
|
||||
|
||||
**_Redash_** consists of two parts:
|
||||
|
||||
1. **Query Editor**: think of [JS Fiddle](http://jsfiddle.net) for SQL queries. It's your way to share data in the organization in an open way, by sharing both the dataset and the query that generated it. This way everyone can peer review not only the resulting dataset but also the process that generated it. Also it's possible to fork it and generate new datasets and reach new insights.
|
||||
1. **Query Editor**: think of [JS Fiddle](https://jsfiddle.net) for SQL queries. It's your way to share data in the organization in an open way, by sharing both the dataset and the query that generated it. This way everyone can peer review not only the resulting dataset but also the process that generated it. Also it's possible to fork it and generate new datasets and reach new insights.
|
||||
2. **Visualizations and Dashboards**: once you have a dataset, you can create different visualizations out of it, and then combine several visualizations into a single dashboard. Currently Redash supports charts, pivot table, cohorts and [more](https://redash.io/help/user-guide/visualizations/visualization-types).
|
||||
|
||||
<img src="https://raw.githubusercontent.com/getredash/website/8e820cd02c73a8ddf4f946a9d293c54fd3fb08b9/website/_assets/images/redash-anim.gif" width="80%"/>
|
||||
|
||||
39
bin/bundle-extensions
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import os
|
||||
from subprocess import call
|
||||
from distutils.dir_util import copy_tree
|
||||
|
||||
from pkg_resources import iter_entry_points, resource_filename, resource_isdir
|
||||
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
if not os.path.exists(EXTENSIONS_DIRECTORY):
|
||||
os.makedirs(EXTENSIONS_DIRECTORY)
|
||||
os.environ["EXTENSIONS_DIRECTORY"] = 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):
|
||||
continue
|
||||
|
||||
content_folder = resource_filename(root_module, content_folder_relative)
|
||||
|
||||
# This is where we place our extensions folder.
|
||||
destination = os.path.join(
|
||||
EXTENSIONS_DIRECTORY,
|
||||
entry_point.name)
|
||||
|
||||
copy_tree(content_folder, destination)
|
||||
7
bin/flake8_tests.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
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
|
||||
# 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,4 +1,5 @@
|
||||
#!/bin/env python
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
@@ -32,4 +33,4 @@ if __name__ == '__main__':
|
||||
changes = get_change_log(previous_sha)
|
||||
|
||||
for change in changes:
|
||||
print change
|
||||
print(change)
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import requests
|
||||
import simplejson
|
||||
|
||||
github_token = os.environ['GITHUB_TOKEN']
|
||||
auth = (github_token, 'x-oauth-basic')
|
||||
@@ -17,7 +17,7 @@ def _github_request(method, path, params=None, headers={}):
|
||||
url = path
|
||||
|
||||
if params is not None:
|
||||
params = json.dumps(params)
|
||||
params = simplejson.dumps(params)
|
||||
|
||||
response = requests.request(method, url, data=params, auth=auth)
|
||||
return response
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: "airbnb",
|
||||
extends: ["airbnb", "plugin:jest/recommended"],
|
||||
plugins: ["jest", "cypress"],
|
||||
settings: {
|
||||
"import/resolver": "webpack"
|
||||
},
|
||||
parser: "babel-eslint",
|
||||
env: {
|
||||
"jest/globals": true,
|
||||
"cypress/globals": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
|
||||
BIN
client/app/assets/images/db-logos/databricks.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
client/app/assets/images/db-logos/db2.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
client/app/assets/images/db-logos/druid.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
client/app/assets/images/db-logos/hive_http.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
client/app/assets/images/db-logos/kylin.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.1 KiB |
BIN
client/app/assets/images/db-logos/rockset.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 12 KiB |
BIN
client/app/assets/images/destinations/pagerduty.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
@@ -40,3 +40,10 @@
|
||||
vertical-align: top;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
// Hide URLs next to links when printing (override `bootstrap` rules)
|
||||
@media print {
|
||||
a[href]:after {
|
||||
content: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,6 @@
|
||||
border-radius: @redash-radius;
|
||||
}
|
||||
|
||||
.edit-in-place input,
|
||||
.edit-in-place textarea {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-in-place.active span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-in-place.active input,
|
||||
.edit-in-place.active textarea {
|
||||
display: inline-block;
|
||||
|
||||
@@ -1,60 +1,78 @@
|
||||
.list-group {
|
||||
margin-bottom: 0;
|
||||
|
||||
&.lg-alt .list-group-item {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:not(.lg-alt) {
|
||||
&.lg-listview .list-group-item {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
&.active {
|
||||
button {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.cr-alt {
|
||||
line-height: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item-heading {
|
||||
margin-bottom: 2px;
|
||||
color: #333;
|
||||
|
||||
& > small {
|
||||
font-size: 11px;
|
||||
color: #C5C5C5;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item-heading,
|
||||
.list-group-item-text {
|
||||
.text-overflow();
|
||||
}
|
||||
|
||||
.list-group-item-text {
|
||||
display: block;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-img {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.list-group {
|
||||
margin-bottom: 0;
|
||||
|
||||
&.lg-alt .list-group-item {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:not(.lg-alt) {
|
||||
&.lg-listview .list-group-item {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags-list {
|
||||
a {
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-list__name {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 88%;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.max-character {
|
||||
.text-overflow();
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
&.active {
|
||||
button {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.cr-alt {
|
||||
line-height: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item-heading {
|
||||
margin-bottom: 2px;
|
||||
color: #333;
|
||||
|
||||
& > small {
|
||||
font-size: 11px;
|
||||
color: #C5C5C5;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item-heading,
|
||||
.list-group-item-text {
|
||||
.text-overflow();
|
||||
}
|
||||
|
||||
.list-group-item-text {
|
||||
display: block;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-img {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -3,52 +3,43 @@ counter-renderer {
|
||||
text-align: center;
|
||||
padding: 15px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
counter-renderer counter {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
font-size: 80px;
|
||||
overflow: hidden;
|
||||
height: 200px;
|
||||
}
|
||||
counter {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 80px;
|
||||
line-height: normal;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
counter-renderer value,
|
||||
counter-renderer counter-name,
|
||||
counter-renderer counter-target {
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
display: block;
|
||||
}
|
||||
value,
|
||||
counter-target {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
counter-renderer value .ruler,
|
||||
counter-renderer counter-name .ruler,
|
||||
counter-renderer counter-target .ruler {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
counter-name {
|
||||
font-size: 0.5em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
counter-renderer counter-target {
|
||||
color: #ccc;
|
||||
}
|
||||
&.positive value {
|
||||
color: #5cb85c;
|
||||
}
|
||||
|
||||
counter-renderer counter.positive value {
|
||||
color: #5cb85c;
|
||||
}
|
||||
&.negative value {
|
||||
color: #d9534f;
|
||||
}
|
||||
}
|
||||
|
||||
counter-renderer counter.negative value {
|
||||
color: #d9534f;
|
||||
margin-right: 15px;
|
||||
}
|
||||
counter-target {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
counter-renderer counter-name {
|
||||
font-size: 0.5em;
|
||||
display: block;
|
||||
counter-name {
|
||||
font-size: 0.5em;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
@import '~antd/lib/style/core/iconfont.less';
|
||||
@import '~antd/lib/input/style/index.less';
|
||||
@import '~antd/lib/date-picker/style/index.less';
|
||||
@import 'redash/ant';
|
||||
|
||||
/** LESS Plugins **/
|
||||
@@ -14,8 +11,8 @@
|
||||
@import '~ui-select/dist/select.css';
|
||||
@import '~angular-toastr/src/toastr';
|
||||
@import '~angular-resizable/src/angular-resizable.css';
|
||||
@import '~pace-progress/themes/blue/pace-theme-minimal.css';
|
||||
@import '~material-design-iconic-font/dist/css/material-design-iconic-font.css';
|
||||
@import '~pace-progress/themes/blue/pace-theme-minimal.css';
|
||||
|
||||
@import 'inc/angular';
|
||||
@import 'inc/variables';
|
||||
@@ -81,6 +78,7 @@
|
||||
@import 'redash/redash-newstyle';
|
||||
@import 'redash/redash-table';
|
||||
@import 'redash/query';
|
||||
@import 'redash/tags-control';
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
@import '~antd/lib/style/core/iconfont.less';
|
||||
@import '~antd/lib/style/core/motion.less';
|
||||
@import '~antd/lib/input/style/index.less';
|
||||
@import '~antd/lib/date-picker/style/index.less';
|
||||
@import '~antd/lib/tooltip/style/index.less';
|
||||
@import '~antd/lib/select/style/index.less';
|
||||
|
||||
// Overwritting Ant Design defaults to fit into Redash current style
|
||||
@font-family-no-number : @redash-font;
|
||||
@font-family : @redash-font;
|
||||
@@ -7,3 +14,25 @@
|
||||
@border-color-base : #e8e8e8;
|
||||
|
||||
@primary-color : @blue;
|
||||
|
||||
// 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
|
||||
// turns it into simple inline element (because now it's wrapper is a button)
|
||||
.btn {
|
||||
button[disabled] {
|
||||
-moz-appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
appearance: none !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
background: transparent !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for Ant dropdowns when they are used in Boootstrap modals
|
||||
.ant-dropdown-in-bootstrap-modal {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
@@ -95,10 +95,6 @@ edit-in-place p.editable:hover {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.source-control {
|
||||
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_completion-highlight {
|
||||
text-shadow: none !important;
|
||||
background: #ffff005e;
|
||||
@@ -225,26 +221,29 @@ edit-in-place p.editable:hover {
|
||||
}
|
||||
|
||||
.page-header--new {
|
||||
.label-default {
|
||||
margin-right: 3px;
|
||||
.query-tags,
|
||||
.query-tags__mobile {
|
||||
.label-default,
|
||||
.label-warning {
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header--query {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
display: inline-block;
|
||||
.page-title {
|
||||
display: block;
|
||||
margin-left: 15px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
a.label-tag {
|
||||
background: fade(@redash-gray, 15%);
|
||||
color: #333;
|
||||
color: darken(@redash-gray, 15%);
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
color: darken(@redash-gray, 15%);
|
||||
background: fade(@redash-gray, 25%);
|
||||
}
|
||||
}
|
||||
@@ -527,9 +526,59 @@ nav .rg-bottom {
|
||||
}
|
||||
}
|
||||
|
||||
.query-tags {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-top: -3px; // padding-top of tags
|
||||
}
|
||||
|
||||
.query-tags__mobile {
|
||||
display: none;
|
||||
margin: -5px 0 0 0;
|
||||
padding: 0 0 0 23px;
|
||||
}
|
||||
|
||||
.table--permission {
|
||||
.profile__image {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mp__permission-type {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
// Smaller screens
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.page-header--query {
|
||||
.page-title {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.query-tags:not(.query-tags__empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.query-tags__mobile:not(.query-tags__empty) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn--showhide,
|
||||
.query-actions-menu .dropdown-toggle {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.btn-publish {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-nav .tab-new-vis {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.query-fullscreen {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -541,44 +590,25 @@ nav .rg-bottom {
|
||||
.schema-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.query-page-wrapper {
|
||||
.container {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
.query-metadata__mobile {
|
||||
border-bottom: 1px solid #efefef;
|
||||
min-height: 0 !important;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.profile__image_thumb {
|
||||
margin: 0 5px 0 0;
|
||||
}
|
||||
|
||||
.query-metadata__property {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-fullscreen .query-metadata__mobile {
|
||||
display: block;
|
||||
border-bottom: 1px solid #efefef;
|
||||
padding: 10px 0;
|
||||
min-height: 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-header--query {
|
||||
padding-left: 0px !important;
|
||||
|
||||
h3 {
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
.query-fullscreen .content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.datasource-small {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.query-fullscreen {
|
||||
|
||||
main {
|
||||
flex-direction: column-reverse;
|
||||
@@ -600,18 +630,31 @@ nav .rg-bottom {
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.static-position__mobile {
|
||||
position: static !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-controller-container {
|
||||
z-index: 9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 438px) {
|
||||
.btn--showhide {
|
||||
margin-bottom: 5px;
|
||||
.query-page-wrapper {
|
||||
.container {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-publish {
|
||||
margin-bottom: 5px;
|
||||
a.navbar-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.datasource-small {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,6 +672,6 @@ nav .rg-bottom {
|
||||
}
|
||||
|
||||
.btn-edit-visualisation {
|
||||
display: none;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import (reference, less) '~bootstrap/less/labels.less';
|
||||
|
||||
// Variables
|
||||
@redash-gray: rgba(102, 136, 153, 1);
|
||||
@redash-orange: rgba(255, 120, 100, 1);
|
||||
@@ -366,8 +368,8 @@ body {
|
||||
|
||||
page-header, .page-header--new {
|
||||
h3 {
|
||||
margin: 0;
|
||||
line-height: 1.75;
|
||||
margin: 0.2em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -449,10 +451,27 @@ page-header, .page-header--new {
|
||||
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-block;
|
||||
margin-top: 2px;
|
||||
max-width: 24ch;
|
||||
.text-overflow();
|
||||
}
|
||||
|
||||
.tab-nav > li > a {
|
||||
@@ -732,6 +751,14 @@ page-header, .page-header--new {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
|
||||
.badge-light {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: 200px;
|
||||
@@ -805,10 +832,6 @@ text.slicetext {
|
||||
}
|
||||
|
||||
.query-page-wrapper {
|
||||
.page-header--query {
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -860,6 +883,16 @@ text.slicetext {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 850px) {
|
||||
.menu-search {
|
||||
width: 175px;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1084px) {
|
||||
.dropdown--profile__username {
|
||||
display: none;
|
||||
|
||||
13
client/app/assets/less/redash/tags-control.less
Normal file
@@ -0,0 +1,13 @@
|
||||
.tags-control {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
line-height: 1em;
|
||||
|
||||
&.inline-tags-control {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
43
client/app/components/AutocompleteToggle.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import PropTypes from 'prop-types';
|
||||
import '@/redash-font/style.less';
|
||||
import recordEvent from '@/lib/recordEvent';
|
||||
|
||||
export default function AutocompleteToggle({ state, disabled, onToggle }) {
|
||||
let tooltipMessage = 'Live Autocomplete Enabled';
|
||||
let icon = 'icon-flash';
|
||||
if (!state) {
|
||||
tooltipMessage = 'Live Autocomplete Disabled';
|
||||
icon = 'icon-flash-off';
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
tooltipMessage = 'Live Autocomplete Not Available (Use Ctrl+Space to Trigger)';
|
||||
icon = 'icon-flash-off';
|
||||
}
|
||||
|
||||
const toggle = (newState) => {
|
||||
recordEvent('toggle_autocomplete', 'screen', 'query_editor', { state: newState });
|
||||
onToggle(newState);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={tooltipMessage}>
|
||||
<button
|
||||
type="button"
|
||||
className={'btn btn-default m-r-5' + (disabled ? ' disabled' : '')}
|
||||
onClick={() => toggle(!state)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<i className={'icon ' + icon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
AutocompleteToggle.propTypes = {
|
||||
state: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -29,3 +29,5 @@ BigMessage.defaultProps = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('bigMessage', react2angular(BigMessage));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -45,3 +45,4 @@ export default function init(ngModule) {
|
||||
ngModule.component('dateInput', react2angular(DateInput, null, ['clientConfig']));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -4,9 +4,6 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { RangePicker } from 'antd/lib/date-picker';
|
||||
import 'antd/lib/style/core/iconfont.less';
|
||||
import 'antd/lib/input/style/index.less';
|
||||
import 'antd/lib/date-picker/style/index.less';
|
||||
|
||||
function DateRangeInput({
|
||||
value,
|
||||
@@ -53,3 +50,4 @@ export default function init(ngModule) {
|
||||
ngModule.component('dateRangeInput', react2angular(DateRangeInput, null, ['clientConfig']));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -50,3 +50,4 @@ export default function init(ngModule) {
|
||||
ngModule.component('dateTimeInput', react2angular(DateTimeInput, null, ['clientConfig']));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -4,9 +4,6 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { RangePicker } from 'antd/lib/date-picker';
|
||||
import 'antd/lib/style/core/iconfont.less';
|
||||
import 'antd/lib/input/style/index.less';
|
||||
import 'antd/lib/date-picker/style/index.less';
|
||||
|
||||
function DateTimeRangeInput({
|
||||
value,
|
||||
@@ -58,3 +55,5 @@ export default function init(ngModule) {
|
||||
ngModule.component('dateTimeRangeInput', react2angular(DateTimeRangeInput, null, ['clientConfig']));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
92
client/app/components/EditInPlace.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
export class EditInPlace extends React.Component {
|
||||
static propTypes = {
|
||||
ignoreBlanks: PropTypes.bool,
|
||||
isEditable: PropTypes.bool,
|
||||
editor: PropTypes.string.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
onDone: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
ignoreBlanks: false,
|
||||
isEditable: true,
|
||||
placeholder: '',
|
||||
value: '',
|
||||
};
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
editing: false,
|
||||
};
|
||||
this.inputRef = React.createRef();
|
||||
const self = this;
|
||||
this.componentDidUpdate = (prevProps, prevState) => {
|
||||
if (self.state.editing && !prevState.editing) {
|
||||
self.inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
startEditing = () => {
|
||||
if (this.props.isEditable) {
|
||||
this.setState({ editing: true });
|
||||
}
|
||||
};
|
||||
|
||||
stopEditing = () => {
|
||||
const newValue = this.inputRef.current.value;
|
||||
const ignorableBlank = this.props.ignoreBlanks && this.props.value === '';
|
||||
if (!ignorableBlank && newValue !== this.props.value) {
|
||||
this.props.onDone(newValue);
|
||||
}
|
||||
this.setState({ editing: false });
|
||||
};
|
||||
|
||||
keyDown = (event) => {
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.stopEditing();
|
||||
} else if (event.keyCode === 27) {
|
||||
this.setState({ editing: false });
|
||||
}
|
||||
};
|
||||
|
||||
renderNormal = () => (
|
||||
<span
|
||||
role="presentation"
|
||||
onFocus={this.startEditing}
|
||||
onClick={this.startEditing}
|
||||
className={this.props.isEditable ? 'editable' : ''}
|
||||
>
|
||||
{this.props.value || this.props.placeholder}
|
||||
</span>
|
||||
);
|
||||
|
||||
renderEdit = () =>
|
||||
React.createElement(this.props.editor, {
|
||||
ref: this.inputRef,
|
||||
className: 'rd-form-control',
|
||||
defaultValue: this.props.value,
|
||||
onBlur: this.stopEditing,
|
||||
onKeyDown: this.keyDown,
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span className={'edit-in-place' + (this.state.editing ? ' active' : '')}>
|
||||
{this.state.editing ? this.renderEdit() : this.renderNormal()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editInPlace', react2angular(EditInPlace));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -3,9 +3,12 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
function Footer({ clientConfig, currentUser }) {
|
||||
const version = clientConfig.version;
|
||||
import frontendVersion from '../version.json';
|
||||
|
||||
export function Footer({ clientConfig, currentUser }) {
|
||||
const backendVersion = clientConfig.version;
|
||||
const newVersionAvailable = clientConfig.newVersionAvailable && currentUser.isAdmin;
|
||||
const separator = ' \u2022 ';
|
||||
|
||||
let newVersionString = '';
|
||||
if (newVersionAvailable) {
|
||||
@@ -18,12 +21,12 @@ function Footer({ clientConfig, currentUser }) {
|
||||
|
||||
return (
|
||||
<div id="footer">
|
||||
<a href="http://redash.io">Redash</a> {version}
|
||||
<a href="https://redash.io">Redash</a> {backendVersion} ({frontendVersion.substring(0, 8)})
|
||||
{newVersionString}
|
||||
•
|
||||
{separator}
|
||||
<a href="https://redash.io/help/">Documentation</a>
|
||||
•
|
||||
<a href="http://github.com/getredash/redash">Contribute</a>
|
||||
{separator}
|
||||
<a href="https://github.com/getredash/redash">Contribute</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,3 +44,5 @@ Footer.propTypes = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('footer', react2angular(Footer, [], ['clientConfig', 'currentUser']));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
16
client/app/components/Footer.test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { Footer } from './Footer';
|
||||
|
||||
test('Footer renders', () => {
|
||||
const clientConfig = {
|
||||
version: '5.0.1',
|
||||
newVersionAvailable: true,
|
||||
};
|
||||
const currentUser = {
|
||||
isAdmin: true,
|
||||
};
|
||||
const component = renderer.create(<Footer clientConfig={clientConfig} currentUser={currentUser} />);
|
||||
const tree = component.toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,17 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { BigMessage } from './BigMessage.jsx';
|
||||
import { BigMessage } from '@/components/BigMessage';
|
||||
import TagsControl from '@/components/tags-control/TagsControl';
|
||||
|
||||
function NoTaggedObjectsFound({ objectType, tags }) {
|
||||
return (
|
||||
<BigMessage icon="fa-tags">
|
||||
No {objectType} found tagged with
|
||||
{Array.from(tags).map(tag => (
|
||||
<span className="label label-tag" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}.
|
||||
No {objectType} found tagged with <TagsControl className="inline-tags-control" tags={Array.from(tags)} />.
|
||||
</BigMessage>
|
||||
);
|
||||
}
|
||||
@@ -24,3 +20,5 @@ NoTaggedObjectsFound.propTypes = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('noTaggedObjectsFound', react2angular(NoTaggedObjectsFound));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
282
client/app/components/QueryEditor.jsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { map } from 'lodash';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
import AceEditor from 'react-ace';
|
||||
import ace from 'brace';
|
||||
import toastr from 'angular-toastr';
|
||||
|
||||
import 'brace/ext/language_tools';
|
||||
import 'brace/mode/json';
|
||||
import 'brace/mode/python';
|
||||
import 'brace/mode/sql';
|
||||
import 'brace/theme/textmate';
|
||||
import 'brace/ext/searchbox';
|
||||
|
||||
import localOptions from '@/lib/localOptions';
|
||||
import AutocompleteToggle from '@/components/AutocompleteToggle';
|
||||
import { DataSource, Schema } from './proptypes';
|
||||
|
||||
const langTools = ace.acequire('ace/ext/language_tools');
|
||||
const snippetsModule = ace.acequire('ace/snippets');
|
||||
|
||||
// By default Ace will try to load snippet files for the different modes and fail.
|
||||
// We don't need them, so we use these placeholders until we define our own.
|
||||
function defineDummySnippets(mode) {
|
||||
ace.define(`ace/snippets/${mode}`, ['require', 'exports', 'module'], (require, exports) => {
|
||||
exports.snippetText = '';
|
||||
exports.scope = mode;
|
||||
});
|
||||
}
|
||||
|
||||
defineDummySnippets('python');
|
||||
defineDummySnippets('sql');
|
||||
defineDummySnippets('json');
|
||||
|
||||
function buildKeywordsFromSchema(schema) {
|
||||
const keywords = {};
|
||||
schema.forEach((table) => {
|
||||
keywords[table.name] = 'Table';
|
||||
table.columns.forEach((c) => {
|
||||
keywords[c] = 'Column';
|
||||
keywords[`${table.name}.${c}`] = 'Column';
|
||||
});
|
||||
});
|
||||
|
||||
return map(keywords, (v, k) => ({
|
||||
name: k,
|
||||
value: k,
|
||||
score: 0,
|
||||
meta: v,
|
||||
}));
|
||||
}
|
||||
|
||||
class QueryEditor extends React.Component {
|
||||
static propTypes = {
|
||||
queryText: PropTypes.string.isRequired,
|
||||
schema: Schema, // eslint-disable-line react/no-unused-prop-types
|
||||
addNewParameter: PropTypes.func.isRequired,
|
||||
dataSources: PropTypes.arrayOf(DataSource),
|
||||
dataSource: DataSource,
|
||||
canEdit: PropTypes.bool.isRequired,
|
||||
isDirty: PropTypes.bool.isRequired,
|
||||
isQueryOwner: PropTypes.bool.isRequired,
|
||||
updateDataSource: PropTypes.func.isRequired,
|
||||
canExecuteQuery: PropTypes.func.isRequired,
|
||||
executeQuery: PropTypes.func.isRequired,
|
||||
queryExecuting: PropTypes.bool.isRequired,
|
||||
saveQuery: PropTypes.func.isRequired,
|
||||
updateQuery: PropTypes.func.isRequired,
|
||||
listenForResize: PropTypes.func.isRequired,
|
||||
listenForEditorCommand: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
schema: null,
|
||||
dataSource: {},
|
||||
dataSources: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
schema: null, // eslint-disable-line react/no-unused-state
|
||||
keywords: [], // eslint-disable-line react/no-unused-state
|
||||
autocompleteQuery: localOptions.get('liveAutocomplete', true),
|
||||
liveAutocompleteDisabled: false,
|
||||
// XXX temporary while interfacing with angular
|
||||
queryText: props.queryText,
|
||||
};
|
||||
langTools.addCompleter({
|
||||
getCompletions: (state, session, pos, prefix, callback) => {
|
||||
if (prefix.length === 0) {
|
||||
callback(null, []);
|
||||
return;
|
||||
}
|
||||
callback(null, this.state.keywords);
|
||||
},
|
||||
});
|
||||
|
||||
this.onLoad = (editor) => {
|
||||
// Release Cmd/Ctrl+L to the browser
|
||||
editor.commands.bindKey('Cmd+L', null);
|
||||
editor.commands.bindKey('Ctrl+P', null);
|
||||
editor.commands.bindKey('Ctrl+L', null);
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
this.props.QuerySnippet.query((snippets) => {
|
||||
const snippetManager = snippetsModule.snippetManager;
|
||||
const m = {
|
||||
snippetText: '',
|
||||
};
|
||||
m.snippets = snippetManager.parseSnippetFile(m.snippetText);
|
||||
snippets.forEach((snippet) => {
|
||||
m.snippets.push(snippet.getSnippet());
|
||||
});
|
||||
snippetManager.register(m.snippets || [], m.scope);
|
||||
});
|
||||
editor.focus();
|
||||
this.props.listenForResize(() => editor.resize());
|
||||
this.props.listenForEditorCommand((e, command, ...args) => {
|
||||
switch (command) {
|
||||
case 'focus': {
|
||||
editor.focus();
|
||||
break;
|
||||
}
|
||||
case 'paste': {
|
||||
const [text] = args;
|
||||
editor.session.doc.replace(editor.selection.getRange(), text);
|
||||
const range = editor.selection.getRange();
|
||||
this.props.updateQuery(editor.session.getValue());
|
||||
editor.selection.setRange(range);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.formatQuery = () => {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const format = this.props.Query.format;
|
||||
format(this.props.dataSource.syntax || 'sql', this.props.queryText)
|
||||
.then(this.updateQuery)
|
||||
.catch(error => toastr.error(error));
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (!nextProps.schema) {
|
||||
return { keywords: [], liveAutocompleteDisabled: false };
|
||||
} else if (nextProps.schema !== prevState.schema) {
|
||||
const tokensCount = nextProps.schema.reduce((totalLength, table) => totalLength + table.columns.length, 0);
|
||||
return {
|
||||
schema: nextProps.schema,
|
||||
keywords: buildKeywordsFromSchema(nextProps.schema),
|
||||
liveAutocompleteDisabled: tokensCount > 5000,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
updateQuery = (queryText) => {
|
||||
this.props.updateQuery(queryText);
|
||||
this.setState({ queryText });
|
||||
};
|
||||
|
||||
toggleAutocomplete = (state) => {
|
||||
this.setState({ autocompleteQuery: state });
|
||||
localOptions.set('liveAutocomplete', state);
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const modKey = this.props.KeyboardShortcuts.modKey;
|
||||
|
||||
const isExecuteDisabled = this.props.queryExecuting || !this.props.canExecuteQuery();
|
||||
|
||||
return (
|
||||
<section style={{ height: '100%' }}>
|
||||
<div className="container p-15 m-b-10" style={{ height: '100%' }}>
|
||||
<div style={{ height: 'calc(100% - 40px)', marginBottom: '0px' }} className="editor__container">
|
||||
<AceEditor
|
||||
ref={this.refEditor}
|
||||
theme="textmate"
|
||||
mode={this.props.dataSource.syntax || 'sql'}
|
||||
value={this.state.queryText}
|
||||
editorProps={{ $blockScrolling: Infinity }}
|
||||
width="100%"
|
||||
height="100%"
|
||||
setOptions={{
|
||||
behavioursEnabled: true,
|
||||
enableSnippets: true,
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: !this.state.liveAutocompleteDisabled && this.state.autocompleteQuery,
|
||||
autoScrollEditorIntoView: true,
|
||||
}}
|
||||
showPrintMargin={false}
|
||||
wrapEnabled={false}
|
||||
onLoad={this.onLoad}
|
||||
onPaste={this.onPaste}
|
||||
onChange={this.updateQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="editor__control">
|
||||
<div className="form-inline d-flex">
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={
|
||||
<span>
|
||||
Add New Parameter (<i>{modKey} + P</i>)
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<button type="button" className="btn btn-default m-r-5" onClick={this.props.addNewParameter}>
|
||||
{{ }}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="top" title="Format Query">
|
||||
<button type="button" className="btn btn-default m-r-5" onClick={this.formatQuery}>
|
||||
<span className="zmdi zmdi-format-indent-increase" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<AutocompleteToggle
|
||||
state={this.state.autocompleteQuery}
|
||||
onToggle={this.toggleAutocomplete}
|
||||
disabled={this.state.liveAutocompleteDisabled}
|
||||
/>
|
||||
<select
|
||||
className="form-control datasource-small flex-fill w-100"
|
||||
onChange={this.props.updateDataSource}
|
||||
disabled={!this.props.isQueryOwner}
|
||||
>
|
||||
{this.props.dataSources.map(ds => (
|
||||
<option label={ds.name} value={ds.id} key={`ds-option-${ds.id}`}>
|
||||
{ds.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{this.props.canEdit ? (
|
||||
<Tooltip placement="top" title={modKey + ' + S'}>
|
||||
<button className="btn btn-default m-l-5" onClick={this.props.saveQuery} title="Save">
|
||||
<span className="fa fa-floppy-o" />
|
||||
<span className="hidden-xs m-l-5">Save</span>
|
||||
{this.props.isDirty ? '*' : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip placement="top" title={modKey + ' + Enter'}>
|
||||
{/*
|
||||
Tooltip wraps disabled buttons with `<span>` and moves all styles
|
||||
and classes to that `<span>`. There is a piece of CSS that fixes
|
||||
button appearance, but also wwe need to add `disabled` class to
|
||||
disabled buttons so it will be assigned to wrapper and make it
|
||||
looking properly
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className={'btn btn-primary m-l-5' + (isExecuteDisabled ? ' disabled' : '')}
|
||||
disabled={isExecuteDisabled}
|
||||
onClick={this.props.executeQuery}
|
||||
>
|
||||
<span className="zmdi zmdi-play" />
|
||||
<span className="hidden-xs m-l-5">Execute</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('queryEditor', react2angular(QueryEditor, null, ['QuerySnippet', 'Query', 'KeyboardShortcuts']));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
37
client/app/components/__snapshots__/Footer.test.js.snap
Normal file
@@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Footer renders 1`] = `
|
||||
<div
|
||||
id="footer"
|
||||
>
|
||||
<a
|
||||
href="https://redash.io"
|
||||
>
|
||||
Redash
|
||||
</a>
|
||||
|
||||
5.0.1
|
||||
(
|
||||
dev
|
||||
)
|
||||
<small>
|
||||
<a
|
||||
href="https://version.redash.io/"
|
||||
>
|
||||
(New Redash version available)
|
||||
</a>
|
||||
</small>
|
||||
•
|
||||
<a
|
||||
href="https://redash.io/help/"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
•
|
||||
<a
|
||||
href="https://github.com/getredash/redash"
|
||||
>
|
||||
Contribute
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
@@ -111,3 +111,6 @@ export default function init(ngModule) {
|
||||
controller,
|
||||
}));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<!--<a href="users" title="Settings"><i class="fa fa-cog"></i></a>-->
|
||||
<!--</li>-->
|
||||
<li class="dropdown" uib-dropdown>
|
||||
<a href="#" class="dropdown-toggle dropdown--profile" uib-dropdown-toggle>
|
||||
<a href="#" class="dropdown-toggle dropdown--profile" uib-dropdown-toggle data-cy="dropdown-profile">
|
||||
<img ng-src="{{ $ctrl.currentUser.profile_image_url }}" class="profile__image--navbar" width="20"/>
|
||||
<span class="dropdown--profile__username" ng-bind="$ctrl.currentUser.name"></span> <span
|
||||
class="caret caret--nav"></span></a>
|
||||
@@ -128,7 +128,7 @@
|
||||
<!-- Search -->
|
||||
<form class="navbar-form navbar-right" role="search" ng-submit="$ctrl.searchQueries()">
|
||||
<div class="input-group menu-search">
|
||||
<input type="text" ng-model="$ctrl.term" class="form-control navbar__search__input" placeholder="Search queries...">
|
||||
<input type="text" ng-model="$ctrl.searchTerm" class="form-control navbar__search__input" placeholder="Search queries...">
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-default"><span class="zmdi zmdi-search"></span></button>
|
||||
</span>
|
||||
|
||||
@@ -40,7 +40,7 @@ function controller($rootScope, $location, $route, $uibModal, Auth, currentUser,
|
||||
};
|
||||
|
||||
this.searchQueries = () => {
|
||||
$location.path('/queries').search({ q: this.term });
|
||||
$location.path('/queries').search({ q: this.searchTerm });
|
||||
$route.reload();
|
||||
};
|
||||
|
||||
@@ -55,3 +55,5 @@ export default function init(ngModule) {
|
||||
controller,
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -82,12 +82,18 @@ class AppViewComponent {
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.factory('$exceptionHandler', () => function exceptionHandler(exception) {
|
||||
handler.process(exception);
|
||||
});
|
||||
ngModule.factory(
|
||||
'$exceptionHandler',
|
||||
() =>
|
||||
function exceptionHandler(exception) {
|
||||
handler.process(exception);
|
||||
},
|
||||
);
|
||||
|
||||
ngModule.component('appView', {
|
||||
template,
|
||||
controller: AppViewComponent,
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -6,14 +6,14 @@ function cancelQueryButton() {
|
||||
taskId: '=',
|
||||
},
|
||||
transclude: true,
|
||||
template: '<button class="btn btn-default" ng-disabled="inProgress" ng-click="cancelExecution()"><i class="zmdi zmdi-spinner zmdi-hc-spin" ng-if="inProgress"></i> Cancel</button>',
|
||||
template:
|
||||
'<button class="btn btn-default" ng-disabled="inProgress" ng-click="cancelExecution()"><i class="zmdi zmdi-spinner zmdi-hc-spin" ng-if="inProgress"></i> Cancel</button>',
|
||||
replace: true,
|
||||
controller($scope, $http, currentUser, Events) {
|
||||
$scope.inProgress = false;
|
||||
|
||||
$scope.cancelExecution = () => {
|
||||
$http.delete(`api/jobs/${$scope.taskId}`).success(() => {
|
||||
});
|
||||
$http.delete(`api/jobs/${$scope.taskId}`).success(() => {});
|
||||
|
||||
let queryId = $scope.queryId;
|
||||
if ($scope.queryId === 'adhoc') {
|
||||
@@ -30,3 +30,5 @@ function cancelQueryButton() {
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('cancelQueryButton', cancelQueryButton);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -114,3 +114,5 @@ const AddWidgetDialog = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('addWidgetDialog', AddWidgetDialog);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -42,3 +42,5 @@ const EditDashboardDialog = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editDashboardDialog', EditDashboardDialog);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -459,3 +459,5 @@ export default function init(ngModule) {
|
||||
ngModule.directive('gridstack', gridstack);
|
||||
ngModule.directive('gridstackItem', gridstackItem);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
12
client/app/components/dashboards/widget-dialog.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-hidden="true" ng-click="$ctrl.dismiss()">×</button>
|
||||
<div class="visualization-title">
|
||||
<query-link query="$ctrl.widget.getQuery()" visualization="$ctrl.widget.visualization" readonly="true"></query-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.widget.getQueryResult()" class="t-body"></visualization-renderer>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" ng-click="$ctrl.dismiss()">Close</button>
|
||||
</div>
|
||||
8
client/app/components/dashboards/widget-dialog.less
Normal file
@@ -0,0 +1,8 @@
|
||||
.visualization-title {
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body.modal-open .dropdown.open {
|
||||
z-index: 10000;
|
||||
}
|
||||
@@ -21,13 +21,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="th-title">
|
||||
<p class="hidden-print">
|
||||
<span ng-hide="$ctrl.canViewQuery">{{$ctrl.widget.getQuery().name}}</span>
|
||||
<query-link query="$ctrl.widget.getQuery()" visualization="$ctrl.widget.visualization" ng-show="$ctrl.canViewQuery"></query-link>
|
||||
</p>
|
||||
<p class="visible-print">
|
||||
<span>{{$ctrl.widget.getQuery().name}}</span>
|
||||
<visualization-name visualization="$ctrl.widget.visualization"/>
|
||||
<p>
|
||||
<query-link query="$ctrl.widget.getQuery()" visualization="$ctrl.widget.visualization"
|
||||
readonly="!$ctrl.canViewQuery"></query-link>
|
||||
</p>
|
||||
<div class="text-muted query--description" ng-bind-html="$ctrl.widget.getQuery().description | markdown"></div>
|
||||
</div>
|
||||
@@ -63,6 +59,7 @@
|
||||
</span>
|
||||
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.refresh()" ng-if="!$ctrl.public"><i class="zmdi zmdi-refresh"></i></button>
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.expandVisualization()"><i class="zmdi zmdi-fullscreen"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import template from './widget.html';
|
||||
import editTextBoxTemplate from './edit-text-box.html';
|
||||
import widgetDialogTemplate from './widget-dialog.html';
|
||||
import './widget.less';
|
||||
import './widget-dialog.less';
|
||||
import './add-widget-dialog.less';
|
||||
|
||||
const WidgetDialog = {
|
||||
template: widgetDialogTemplate,
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
controller() {
|
||||
this.widget = this.resolve.widget;
|
||||
},
|
||||
};
|
||||
|
||||
const EditTextBoxComponent = {
|
||||
template: editTextBoxTemplate,
|
||||
bindings: {
|
||||
@@ -51,6 +65,16 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
|
||||
});
|
||||
};
|
||||
|
||||
this.expandVisualization = () => {
|
||||
$uibModal.open({
|
||||
component: 'widgetDialog',
|
||||
resolve: {
|
||||
widget: this.widget,
|
||||
},
|
||||
size: 'lg',
|
||||
});
|
||||
};
|
||||
|
||||
this.localParametersDefs = () => {
|
||||
if (!this.localParameters) {
|
||||
this.localParameters = this.widget
|
||||
@@ -66,8 +90,6 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
|
||||
return;
|
||||
}
|
||||
|
||||
Events.record('delete', 'widget', this.widget.id);
|
||||
|
||||
this.widget.delete().then(() => {
|
||||
if (this.deleted) {
|
||||
this.deleted({});
|
||||
@@ -101,6 +123,7 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editTextBox', EditTextBoxComponent);
|
||||
ngModule.component('widgetDialog', WidgetDialog);
|
||||
ngModule.component('dashboardWidget', {
|
||||
template,
|
||||
controller: DashboardWidgetCtrl,
|
||||
@@ -112,3 +135,6 @@ export default function init(ngModule) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
<div class="form-group" ng-class='{"has-error": (inner.input | showError), "required": field.property.required}' ng-form="inner" ng-repeat="field in fields">
|
||||
<label ng-if="field.property.type !== 'checkbox'" class="control-label">{{field.property.title || field.name | toHuman}}</label>
|
||||
<input name="input" type="{{field.property.type}}" class="form-control" ng-model="target.options[field.name]" ng-required="field.property.required"
|
||||
ng-if="field.property.type !== 'file' && field.property.type !== 'checkbox'" accesskey="tab" placeholder="{{field.property.default}}">
|
||||
ng-if="field.property.type !== 'file' && field.property.type !== 'checkbox'" accesskey="tab" placeholder="{{field.property.default}}"
|
||||
data-cy="{{field.property.title || field.name | toHuman}}">
|
||||
|
||||
<label ng-if="field.property.type=='checkbox'">
|
||||
<input name="input" type="{{field.property.type}}" ng-model="target.options[field.name]" ng-required="field.property.required"
|
||||
ng-if="field.property.type !== 'file'" accesskey="tab" placeholder="{{field.property.default}}">
|
||||
ng-if="field.property.type !== 'file'" accesskey="tab" placeholder="{{field.property.default}}"
|
||||
data-cy="{{field.property.title || field.name | toHuman}}">
|
||||
{{field.property.title || field.name | toHuman}}
|
||||
</label>
|
||||
|
||||
|
||||
@@ -119,3 +119,6 @@ function DynamicForm($http, toastr) {
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('dynamicForm', DynamicForm);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -43,3 +43,6 @@ export default function init(ngModule) {
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -16,10 +16,15 @@ export default function init(ngModule) {
|
||||
$scope.$watch('render', () => {
|
||||
if (isFunction($scope.render)) {
|
||||
$scope.render($scope, (clonedElement) => {
|
||||
$element.empty().append(clonedElement).append('<td></td>');
|
||||
$element
|
||||
.empty()
|
||||
.append(clonedElement)
|
||||
.append('<td></td>');
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.dynamic-table-container {
|
||||
overflow-x: scroll;
|
||||
overflow-x: auto;
|
||||
|
||||
th {
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -2,8 +2,12 @@ import { find, filter, map, each } from 'lodash';
|
||||
import template from './dynamic-table.html';
|
||||
import './dynamic-table.less';
|
||||
|
||||
function isNullOrUndefined(v) {
|
||||
return v === null || v === undefined;
|
||||
}
|
||||
|
||||
function filterRows(rows, searchTerm, columns) {
|
||||
if ((searchTerm === '') || (columns.length === 0) || (rows.length === 0)) {
|
||||
if (searchTerm === '' || columns.length === 0 || rows.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
searchTerm = searchTerm.toUpperCase();
|
||||
@@ -24,7 +28,7 @@ function filterRows(rows, searchTerm, columns) {
|
||||
}
|
||||
|
||||
function sortRows(rows, orderBy) {
|
||||
if ((orderBy.length === 0) || (rows.length === 0)) {
|
||||
if (orderBy.length === 0 || rows.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
// Create a copy of array before sorting, because .sort() will modify original array
|
||||
@@ -34,11 +38,11 @@ function sortRows(rows, orderBy) {
|
||||
for (let i = 0; i < orderBy.length; i += 1) {
|
||||
va = a[orderBy[i].name];
|
||||
vb = b[orderBy[i].name];
|
||||
if (va < vb) {
|
||||
if (isNullOrUndefined(va) || va < vb) {
|
||||
// if a < b - we should return -1, but take in account direction
|
||||
return orderBy[i].direction * -1;
|
||||
}
|
||||
if (va > vb) {
|
||||
if (va > vb || isNullOrUndefined(vb)) {
|
||||
// if a > b - we should return 1, but take in account direction
|
||||
return orderBy[i].direction * 1;
|
||||
}
|
||||
@@ -48,7 +52,7 @@ function sortRows(rows, orderBy) {
|
||||
}
|
||||
|
||||
function validateItemsPerPage(value, defaultValue) {
|
||||
defaultValue = defaultValue || 10;
|
||||
defaultValue = defaultValue || 25;
|
||||
value = parseInt(value, 10) || defaultValue;
|
||||
return value > 0 ? value : defaultValue;
|
||||
}
|
||||
@@ -106,10 +110,7 @@ function DynamicTable($compile) {
|
||||
|
||||
const updateRowsToDisplay = (performFilterAndSort) => {
|
||||
if (performFilterAndSort) {
|
||||
this.preparedRows = sortRows(
|
||||
filterRows(this.rows, this.searchTerm, this.searchColumns),
|
||||
this.orderBy,
|
||||
);
|
||||
this.preparedRows = sortRows(filterRows(this.rows, this.searchTerm, this.searchColumns), this.orderBy);
|
||||
}
|
||||
const first = (this.currentPage - 1) * this.itemsPerPage;
|
||||
const last = first + this.itemsPerPage;
|
||||
@@ -173,7 +174,6 @@ function DynamicTable($compile) {
|
||||
updateOrderByColumnsInfo();
|
||||
updateRowsToDisplay(true);
|
||||
|
||||
|
||||
// Remove text selection - may occur accidentally
|
||||
if ($event.shiftKey) {
|
||||
document.getSelection().removeAllRanges();
|
||||
@@ -185,10 +185,7 @@ function DynamicTable($compile) {
|
||||
};
|
||||
|
||||
this.onSearchTermChanged = () => {
|
||||
this.preparedRows = sortRows(
|
||||
filterRows(this.rows, this.searchTerm, this.searchColumns),
|
||||
this.orderBy,
|
||||
);
|
||||
this.preparedRows = sortRows(filterRows(this.rows, this.searchTerm, this.searchColumns), this.orderBy);
|
||||
this.currentPage = 1;
|
||||
updateRowsToDisplay(true);
|
||||
};
|
||||
@@ -226,3 +223,5 @@ export default function init(ngModule) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -5,7 +5,7 @@ import template from './template.html';
|
||||
const MAX_JSON_SIZE = 50000;
|
||||
|
||||
function parseValue(value) {
|
||||
if (isString(value) && (value.length <= MAX_JSON_SIZE)) {
|
||||
if (isString(value) && value.length <= MAX_JSON_SIZE) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
@@ -38,3 +38,5 @@ export default function init(ngModule) {
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import $ from 'jquery';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
// From: http://jsfiddle.net/joshdmiller/NDFHg/
|
||||
function EditInPlace() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
value: '=',
|
||||
ignoreBlanks: '=',
|
||||
editable: '=',
|
||||
done: '=',
|
||||
},
|
||||
template(tElement, tAttrs) {
|
||||
const elType = tAttrs.editor || 'input';
|
||||
const placeholder = tAttrs.placeholder || 'Click to edit';
|
||||
|
||||
let viewMode = '';
|
||||
|
||||
if (tAttrs.markdown === 'true') {
|
||||
viewMode = '<span ng-click="editable && edit()" ng-bind-html="value|markdown" ng-class="{editable: editable}"></span>';
|
||||
} else {
|
||||
viewMode = '<span ng-click="editable && edit()" ng-bind="value" ng-class="{editable: editable}"></span>';
|
||||
}
|
||||
|
||||
const placeholderSpan = `<span ng-click="editable && edit()"
|
||||
ng-show="editable && !value"
|
||||
ng-class="{editable: editable}">${placeholder}</span>`;
|
||||
const editor = '<{elType} ng-model="value" class="rd-form-control"></{elType}>'.replace('{elType}', elType);
|
||||
|
||||
return viewMode + placeholderSpan + editor;
|
||||
},
|
||||
link($scope, element) {
|
||||
// Let's get a reference to the input element, as we'll want to reference it.
|
||||
const inputElement = $(element.children()[2]);
|
||||
const keycodeEnter = 13;
|
||||
const keycodeEscape = 27;
|
||||
|
||||
// This directive should have a set class so we can style it.
|
||||
element.addClass('edit-in-place');
|
||||
|
||||
// Initially, we're not editing.
|
||||
$scope.editing = false;
|
||||
|
||||
// ng-click handler to activate edit-in-place
|
||||
$scope.edit = () => {
|
||||
$scope.oldValue = $scope.value;
|
||||
|
||||
$scope.editing = true;
|
||||
|
||||
// We control display through a class on the directive itself. See the CSS.
|
||||
element.addClass('active');
|
||||
|
||||
// And we must focus the element.
|
||||
// `angular.element()` provides a chainable array, like jQuery so to access
|
||||
// a native DOM function, we have to reference the first element in the array.
|
||||
inputElement[0].focus();
|
||||
};
|
||||
|
||||
function save() {
|
||||
if ($scope.editing) {
|
||||
if ($scope.ignoreBlanks && isEmpty($scope.value)) {
|
||||
$scope.value = $scope.oldValue;
|
||||
}
|
||||
$scope.editing = false;
|
||||
element.removeClass('active');
|
||||
|
||||
if ($scope.value !== $scope.oldValue) {
|
||||
if ($scope.done) {
|
||||
$scope.done();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$(inputElement).keydown((e) => {
|
||||
// 'return' or 'enter' key pressed
|
||||
// allow 'shift' to break lines
|
||||
if (e.which === keycodeEnter && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.which === keycodeEscape) {
|
||||
$scope.value = $scope.oldValue;
|
||||
$scope.$apply(() => {
|
||||
$(inputElement[0]).blur();
|
||||
});
|
||||
}
|
||||
}).blur(() => {
|
||||
save();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('editInPlace', EditInPlace);
|
||||
}
|
||||
@@ -7,7 +7,10 @@ export default function init(ngModule) {
|
||||
bindings: {
|
||||
function: '<',
|
||||
},
|
||||
template: '<p class="alert alert-danger" ng-if="$ctrl.showMailWarning">It looks like your mail server isn\'t configured. Make sure to configure it for the {{$ctrl.function}} to work.</p>',
|
||||
template:
|
||||
'<p class="alert alert-danger" ng-if="$ctrl.showMailWarning">It looks like your mail server isn\'t configured. Make sure to configure it for the {{$ctrl.function}} to work.</p>',
|
||||
controller,
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -51,3 +51,5 @@ const EmptyStateComponent = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('emptyState', EmptyStateComponent);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -11,10 +11,11 @@ const ErrorMessagesComponent = {
|
||||
input: '<',
|
||||
form: '<',
|
||||
},
|
||||
controller() {
|
||||
},
|
||||
controller() {},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('errorMessages', ErrorMessagesComponent);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -38,3 +38,4 @@ export default function init(ngModule) {
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -23,7 +23,8 @@ const FiltersComponent = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('filters', FiltersComponent);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -37,3 +37,5 @@ const EditGroupDialogComponent = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editGroupDialog', EditGroupDialogComponent);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
function controller($window, $location, toastr, currentUser) {
|
||||
this.canEdit = () => currentUser.isAdmin && this.group.type !== 'builtin';
|
||||
|
||||
this.saveName = () => {
|
||||
this.saveName = (name) => {
|
||||
this.group.name = name;
|
||||
this.group.$save();
|
||||
};
|
||||
|
||||
@@ -23,7 +24,8 @@ export default function init(ngModule) {
|
||||
transclude: true,
|
||||
template: `
|
||||
<h2 class="m-t-0">
|
||||
<edit-in-place editable="$ctrl.canEdit()" done="$ctrl.saveName" ignore-blanks='true' value="$ctrl.group.name"></edit-in-place>
|
||||
<edit-in-place class="edit-in-place" is-editable="$ctrl.canEdit()" on-done="$ctrl.saveName"
|
||||
ignore-blanks="true" value="$ctrl.group.name" editor="'input'"></edit-in-place>
|
||||
<button class="btn btn-xs btn-danger" ng-if="$ctrl.canEdit()" ng-click="$ctrl.deleteGroup()">Delete this group</button>
|
||||
</h2>
|
||||
`,
|
||||
@@ -31,3 +33,5 @@ export default function init(ngModule) {
|
||||
controller,
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -14,3 +14,5 @@ const Overlay = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('overlay', Overlay);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import template from './page-header.html';
|
||||
|
||||
function controller() {
|
||||
|
||||
}
|
||||
function controller() {}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('pageHeader', {
|
||||
@@ -14,3 +12,5 @@ export default function init(ngModule) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -26,3 +26,5 @@ export default function init(ngModule) {
|
||||
controller: PaginatorCtrl,
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -132,16 +132,20 @@ function ParametersDirective($location, $uibModal) {
|
||||
link(scope) {
|
||||
// is this the correct location for this logic?
|
||||
if (scope.syncValues !== false) {
|
||||
scope.$watch('parameters', () => {
|
||||
if (scope.changed) {
|
||||
scope.changed({});
|
||||
}
|
||||
const params = extend({}, $location.search());
|
||||
scope.parameters.forEach((param) => {
|
||||
extend(params, param.toUrlParams());
|
||||
});
|
||||
$location.search(params);
|
||||
}, true);
|
||||
scope.$watch(
|
||||
'parameters',
|
||||
() => {
|
||||
if (scope.changed) {
|
||||
scope.changed({});
|
||||
}
|
||||
const params = extend({}, $location.search());
|
||||
scope.parameters.forEach((param) => {
|
||||
extend(params, param.toUrlParams());
|
||||
});
|
||||
$location.search(params);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
scope.showParameterSettings = (param) => {
|
||||
@@ -184,3 +188,5 @@ export default function init(ngModule) {
|
||||
ngModule.component('parameterSettings', ParameterSettingsComponent);
|
||||
ngModule.component('parameterInput', ParameterInputComponent);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { includes, each } from 'lodash';
|
||||
import { includes, each, filter } from 'lodash';
|
||||
import template from './permissions-editor.html';
|
||||
|
||||
const PermissionsEditorComponent = {
|
||||
@@ -14,6 +14,7 @@ const PermissionsEditorComponent = {
|
||||
this.grantees = [];
|
||||
this.newGrantees = {};
|
||||
this.aclUrl = this.resolve.aclUrl.url;
|
||||
this.owner = this.resolve.owner;
|
||||
|
||||
// List users that are granted permissions
|
||||
const loadGrantees = () => {
|
||||
@@ -39,7 +40,7 @@ const PermissionsEditorComponent = {
|
||||
}
|
||||
|
||||
User.query({ q: search }, (response) => {
|
||||
const users = response.results;
|
||||
const users = filter(response.results, u => u.id !== this.owner.id);
|
||||
const existingIds = this.grantees.map(m => m.id);
|
||||
users.forEach((user) => {
|
||||
user.alreadyGrantee = includes(existingIds, user.id);
|
||||
@@ -50,7 +51,7 @@ const PermissionsEditorComponent = {
|
||||
|
||||
// Add new user to grantees list
|
||||
this.addGrantee = (user) => {
|
||||
this.newGrantees.selected = undefined;
|
||||
this.newGrantees = {};
|
||||
const body = { access_type: 'modify', user_id: user.id };
|
||||
$http.post(this.aclUrl, body).success(() => {
|
||||
user.alreadyGrantee = true;
|
||||
@@ -90,3 +91,6 @@ const PermissionsEditorComponent = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('permissionsEditor', PermissionsEditorComponent);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -4,21 +4,22 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="overflow: auto; height: 300px">
|
||||
<ui-select ng-model="$ctrl.newGrantee.selected" on-select="$ctrl.addGrantee($item)">
|
||||
<ui-select ng-model="$ctrl.newGrantees.selected" on-select="$ctrl.addGrantee($item)">
|
||||
<ui-select-match placeholder="Add New User"></ui-select-match>
|
||||
<ui-select-choices repeat="user in $ctrl.foundUsers | filter:$select.search"
|
||||
refresh="$ctrl.findUser($select.search)"
|
||||
refresh-delay="0"
|
||||
ui-disable-choice="user.alreadyGrantee">
|
||||
<div>
|
||||
<img ng-src="{{ user.profile_image_url }}" class="profile__image" height="24px"> <span
|
||||
ng-class="{'text-muted': user.is_disabled}">{{user.name}}</span>
|
||||
<small ng-if="user.alreadyGrantee">(already has permission)</small>
|
||||
<div class="d-flex align-items-center">
|
||||
<img ng-src="{{ user.profile_image_url }}" class="profile__image" height="24px">
|
||||
<span ng-class="{'text-muted': user.is_disabled}">{{user.name}}</span>
|
||||
<small ng-if="user.alreadyGrantee">(already has permission)</small>
|
||||
</div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
<br/>
|
||||
<table class="table table-condensed table-hover">
|
||||
<br>
|
||||
<h5>Who has access</h5>
|
||||
<table class="table table-condensed table-hover table--permission">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
@@ -28,10 +29,16 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="grantee in $ctrl.grantees">
|
||||
<td width="50px"><img ng-src="{{ grantee.profile_image_url }}" class="profile__image" height="40px"/></td>
|
||||
<tr>
|
||||
<td width="32px"><img ng-src="{{ $ctrl.owner.profile_image_url }}" class="profile__image" height="32px"/></td>
|
||||
<td class="text-muted"> {{ $ctrl.owner.name}}</td>
|
||||
<td class="mp__permission-type">Owner</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr ng-repeat="grantee in $ctrl.grantees" ng-if="grantee.id != $ctrl.owner.id">
|
||||
<td width="32px"><img ng-src="{{ grantee.profile_image_url }}" class="profile__image" height="32px"/></td>
|
||||
<td ng-class="{'text-muted': grantee.is_disabled}">{{grantee.name}}</td>
|
||||
<td>{{grantee.access_type}}</td>
|
||||
<td class="mp__permission-type">{{grantee.access_type}}</td>
|
||||
<td><button class="pull-right btn btn-sm btn-danger" ng-click="$ctrl.removeGrantee(grantee)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
16
client/app/components/proptypes.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const DataSource = PropTypes.shape({
|
||||
syntax: PropTypes.string,
|
||||
options: PropTypes.shape({
|
||||
doc: PropTypes.string,
|
||||
doc_url: PropTypes.string,
|
||||
}),
|
||||
type_name: PropTypes.string,
|
||||
});
|
||||
|
||||
export const Table = PropTypes.shape({
|
||||
columns: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
});
|
||||
|
||||
export const Schema = PropTypes.arrayOf(Table);
|
||||
@@ -35,3 +35,6 @@ function alertUnsavedChanges($window) {
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('alertUnsavedChanges', alertUnsavedChanges);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -35,3 +35,5 @@ const ApiKeyDialog = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('apiKeyDialog', ApiKeyDialog);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -7,7 +7,9 @@ const EmbedCodeDialog = {
|
||||
this.query = this.resolve.query;
|
||||
this.visualization = this.resolve.visualization;
|
||||
|
||||
this.embedUrl = `${clientConfig.basePath}embed/query/${this.query.id}/visualization/${this.visualization.id}?api_key=${this.query.api_key}`;
|
||||
this.embedUrl = `${clientConfig.basePath}embed/query/${this.query.id}/visualization/${
|
||||
this.visualization.id
|
||||
}?api_key=${this.query.api_key}`;
|
||||
if (window.snapshotUrlBuilder) {
|
||||
this.snapshotUrl = window.snapshotUrlBuilder(this.query, this.visualization);
|
||||
}
|
||||
@@ -23,3 +25,5 @@ const EmbedCodeDialog = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('embedCodeDialog', EmbedCodeDialog);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import 'brace';
|
||||
import 'brace/mode/python';
|
||||
import 'brace/mode/sql';
|
||||
import 'brace/mode/json';
|
||||
import 'brace/ext/language_tools';
|
||||
import 'brace/ext/searchbox';
|
||||
import { map } from 'lodash';
|
||||
|
||||
// By default Ace will try to load snippet files for the different modes and fail.
|
||||
// We don't need them, so we use these placeholders until we define our own.
|
||||
function defineDummySnippets(mode) {
|
||||
window.ace.define(`ace/snippets/${mode}`, ['require', 'exports', 'module'], (require, exports) => {
|
||||
exports.snippetText = '';
|
||||
exports.scope = mode;
|
||||
});
|
||||
}
|
||||
|
||||
defineDummySnippets('python');
|
||||
defineDummySnippets('sql');
|
||||
defineDummySnippets('json');
|
||||
|
||||
function queryEditor(QuerySnippet, $timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
query: '=',
|
||||
schema: '=',
|
||||
syntax: '=',
|
||||
},
|
||||
template: '<div ui-ace="editorOptions" ng-model="query.query"></div>',
|
||||
link: {
|
||||
pre($scope) {
|
||||
$scope.syntax = $scope.syntax || 'sql';
|
||||
|
||||
$scope.editorOptions = {
|
||||
mode: 'json',
|
||||
// require: ['ace/ext/language_tools'],
|
||||
advanced: {
|
||||
behavioursEnabled: true,
|
||||
enableSnippets: true,
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
autoScrollEditorIntoView: true,
|
||||
},
|
||||
onLoad(editor) {
|
||||
$scope.$on('query-editor.command', ($event, command, ...args) => {
|
||||
switch (command) {
|
||||
case 'focus': {
|
||||
editor.focus();
|
||||
break;
|
||||
}
|
||||
case 'paste': {
|
||||
const [text] = args;
|
||||
editor.session.doc.replace(editor.selection.getRange(), text);
|
||||
const range = editor.selection.getRange();
|
||||
$scope.query.query = editor.session.getValue();
|
||||
$timeout(() => {
|
||||
editor.selection.setRange(range);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Release Cmd/Ctrl+L to the browser
|
||||
editor.commands.bindKey('Cmd+L', null);
|
||||
editor.commands.bindKey('Ctrl+P', null);
|
||||
editor.commands.bindKey('Ctrl+L', null);
|
||||
|
||||
QuerySnippet.query((snippets) => {
|
||||
window.ace.acequire(['ace/snippets'], (snippetsModule) => {
|
||||
const snippetManager = snippetsModule.snippetManager;
|
||||
const m = {
|
||||
snippetText: '',
|
||||
};
|
||||
m.snippets = snippetManager.parseSnippetFile(m.snippetText);
|
||||
snippets.forEach((snippet) => {
|
||||
m.snippets.push(snippet.getSnippet());
|
||||
});
|
||||
|
||||
snippetManager.register(m.snippets || [], m.scope);
|
||||
});
|
||||
});
|
||||
|
||||
editor.$blockScrolling = Infinity;
|
||||
editor.getSession().setUseWrapMode(true);
|
||||
editor.setShowPrintMargin(false);
|
||||
|
||||
$scope.$watch('syntax', (syntax) => {
|
||||
const newMode = `ace/mode/${syntax}`;
|
||||
editor.getSession().setMode(newMode);
|
||||
});
|
||||
|
||||
$scope.$watch('schema', (newSchema, oldSchema) => {
|
||||
if (newSchema !== oldSchema) {
|
||||
if (newSchema === undefined) {
|
||||
return;
|
||||
}
|
||||
const tokensCount = newSchema.reduce((totalLength, table) => totalLength + table.columns.length, 0);
|
||||
// If there are too many tokens we disable live autocomplete,
|
||||
// as it makes typing slower.
|
||||
if (tokensCount > 5000) {
|
||||
editor.setOption('enableLiveAutocompletion', false);
|
||||
} else {
|
||||
editor.setOption('enableLiveAutocompletion', true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$parent.$on('angular-resizable.resizing', () => {
|
||||
editor.resize();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
},
|
||||
};
|
||||
|
||||
const schemaCompleter = {
|
||||
getCompletions(state, session, pos, prefix, callback) {
|
||||
if (prefix.length === 0 || !$scope.schema) {
|
||||
callback(null, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$scope.schema.keywords) {
|
||||
const keywords = {};
|
||||
|
||||
$scope.schema.forEach((table) => {
|
||||
keywords[table.name] = 'Table';
|
||||
|
||||
table.columns.forEach((c) => {
|
||||
keywords[c] = 'Column';
|
||||
keywords[`${table.name}.${c}`] = 'Column';
|
||||
});
|
||||
});
|
||||
|
||||
$scope.schema.keywords = map(keywords, (v, k) => ({
|
||||
name: k,
|
||||
value: k,
|
||||
score: 0,
|
||||
meta: v,
|
||||
}));
|
||||
}
|
||||
callback(null, $scope.schema.keywords);
|
||||
},
|
||||
};
|
||||
|
||||
window.ace.acequire(['ace/ext/language_tools'], (langTools) => {
|
||||
langTools.addCompleter(schemaCompleter);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('queryEditor', queryEditor);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ function queryResultLink() {
|
||||
restrict: 'A',
|
||||
link(scope, element, attrs) {
|
||||
const fileType = attrs.fileType ? attrs.fileType : 'csv';
|
||||
scope.$watch('queryResult && queryResult.getData()', (data) => {
|
||||
scope.$watch('queryResult && queryResult.getData() && query.name', (data) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function queryResultLink() {
|
||||
element.attr('href', url);
|
||||
element.attr(
|
||||
'download',
|
||||
`${scope.query.name.replace(' ', '_') +
|
||||
`${scope.query.name.replace(/ /g, '_') +
|
||||
moment(scope.queryResult.getUpdatedAt()).format('_YYYY_MM_DD')}.${fileType}`,
|
||||
);
|
||||
}
|
||||
@@ -36,3 +36,6 @@ function queryResultLink() {
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('queryResultLink', queryResultLink);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ function queryTimePicker() {
|
||||
saveQuery: '=',
|
||||
},
|
||||
template: `
|
||||
<select ng-disabled="refreshType != 'daily'" ng-model="hour" ng-change="updateSchedule()"
|
||||
<select ng-disabled="refreshType != 'daily'" ng-model="hour" ng-change="updateSchedule()"
|
||||
ng-options="c as c for c in hourOptions"></select> :
|
||||
<select ng-disabled="refreshType != 'daily'" ng-model="minute" ng-change="updateSchedule()"
|
||||
<select ng-disabled="refreshType != 'daily'" ng-model="minute" ng-change="updateSchedule()"
|
||||
ng-options="c as c for c in minuteOptions"></select>
|
||||
`,
|
||||
link($scope) {
|
||||
@@ -127,3 +127,5 @@ export default function init(ngModule) {
|
||||
ngModule.directive('queryRefreshSelect', queryRefreshSelect);
|
||||
ngModule.component('scheduleDialog', ScheduleForm);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="schema-container" ng-if="$ctrl.schema">
|
||||
<div class="schema-control">
|
||||
<input type="text" placeholder="Search schema..." class="form-control" ng-model="$ctrl.schemaFilter" ng-model-options="{ debounce: 500 }" ng-disabled="$ctrl.isEmpty()">
|
||||
<input type="text" placeholder="Search schema..." class="form-control" ng-model="$ctrl.schemaFilter" ng-model-options="{ debounce: 500 }" ng-disabled="$ctrl.isEmpty()" ng-change="$ctrl.splitFilter($ctrl.schemaFilter);">
|
||||
<button class="btn btn-default"
|
||||
title="Refresh Schema"
|
||||
ng-click="$ctrl.onRefresh()">
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<div class="schema-browser" vs-repeat vs-size="$ctrl.getSize(table)">
|
||||
<div ng-repeat="table in $ctrl.schema | filter:$ctrl.schemaFilter track by table.name">
|
||||
<div ng-repeat="table in $ctrl.schema | filter:$ctrl.schemaFilterObject track by table.name">
|
||||
<div class="table-name" ng-click="$ctrl.showTable(table)">
|
||||
<i class="fa fa-table"></i>
|
||||
<strong>
|
||||
@@ -20,7 +20,7 @@
|
||||
ng-click="$ctrl.itemSelected($event, [table.name])"></i>
|
||||
</div>
|
||||
<div uib-collapse="table.collapsed">
|
||||
<div ng-repeat="column in table.columns track by column" class="table-open">{{column}}
|
||||
<div ng-repeat="column in table.columns | filter:$ctrl.schemaFilterColumn track by column" class="table-open">{{column}}
|
||||
<i class="fa fa-angle-double-right copy-to-editor" aria-hidden="true"
|
||||
ng-click="$ctrl.itemSelected($event, [column])"></i>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,18 @@ function SchemaBrowserCtrl($rootScope, $scope) {
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
};
|
||||
|
||||
this.splitFilter = (filter) => {
|
||||
filter = filter.replace(/ {2}/g, ' ');
|
||||
if (filter.includes(' ')) {
|
||||
const splitTheFilter = filter.split(' ');
|
||||
this.schemaFilterObject = { name: splitTheFilter[0], columns: splitTheFilter[1] };
|
||||
this.schemaFilterColumn = splitTheFilter[1];
|
||||
} else {
|
||||
this.schemaFilterObject = filter;
|
||||
this.schemaFilterColumn = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const SchemaBrowser = {
|
||||
@@ -41,3 +53,6 @@ const SchemaBrowser = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('schemaBrowser', SchemaBrowser);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -51,3 +51,6 @@ export default function init(ngModule) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -18,13 +18,16 @@ export default function init(ngModule) {
|
||||
bindings: {
|
||||
query: '<',
|
||||
visualization: '<',
|
||||
readonly: '<',
|
||||
},
|
||||
template: `
|
||||
<a ng-href="{{$ctrl.link}}" class="query-link">
|
||||
<visualization-name visualization="$ctrl.visualization"/>
|
||||
<a ng-href="{{$ctrl.readonly ? undefined : $ctrl.link}}" class="query-link">
|
||||
<visualization-name visualization="$ctrl.visualization"/>
|
||||
<span>{{$ctrl.query.name}}</span>
|
||||
</a>
|
||||
`,
|
||||
controller: QueryLinkController,
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -22,3 +22,5 @@ function rdTab($location) {
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('rdTab', rdTab);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -13,3 +13,6 @@ const RdTimeAgo = {
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('rdTimeAgo', RdTimeAgo);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -28,3 +28,6 @@ function rdTimer() {
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('rdTimer', rdTimer);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -18,3 +18,6 @@ export default function init(ngModule) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -24,3 +24,6 @@ export default function init(ngModule) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||
@@ -19,3 +19,6 @@ export default function init(ngModule) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
|
||||