mirror of
https://github.com/getredash/redash.git
synced 2025-12-26 12:00:55 -05:00
Compare commits
100 Commits
release/8.
...
param-feed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b3f31bdce | ||
|
|
13e5500718 | ||
|
|
5213b524b4 | ||
|
|
e20b2b5dd3 | ||
|
|
ac77587335 | ||
|
|
c553f006d9 | ||
|
|
88ae639ee4 | ||
|
|
ba413c210e | ||
|
|
7157244eec | ||
|
|
9f7844640a | ||
|
|
246eca1121 | ||
|
|
7ffb97232e | ||
|
|
8b9fa53efe | ||
|
|
43b35b6fb4 | ||
|
|
f0f85ece42 | ||
|
|
612833404b | ||
|
|
5d58503623 | ||
|
|
3dfad87266 | ||
|
|
0659ef1079 | ||
|
|
a2e21dd1c3 | ||
|
|
f165cad9ff | ||
|
|
e0a2705c1a | ||
|
|
0aca649cb5 | ||
|
|
79b37e8843 | ||
|
|
72bb5d29a0 | ||
|
|
5a5fdecdde | ||
|
|
f6e1470a7c | ||
|
|
27cd76797e | ||
|
|
29b113005c | ||
|
|
53d971bf87 | ||
|
|
a102e93e50 | ||
|
|
74beed80d2 | ||
|
|
39f038f992 | ||
|
|
da2ed56281 | ||
|
|
9d8812a598 | ||
|
|
204447a9f5 | ||
|
|
3b7efb8c1f | ||
|
|
69dc761c60 | ||
|
|
2f42b8154c | ||
|
|
3f9d49dbd1 | ||
|
|
0a5dca5d72 | ||
|
|
8ea285dda9 | ||
|
|
569c325aa0 | ||
|
|
d8a0af1a95 | ||
|
|
648847df0b | ||
|
|
3f31bf3fc0 | ||
|
|
f6ad3d9d24 | ||
|
|
e8ccdc23c7 | ||
|
|
a8af968d70 | ||
|
|
cb14459881 | ||
|
|
780fbceba5 | ||
|
|
2c77c219c6 | ||
|
|
874e0d1ce3 | ||
|
|
401d164622 | ||
|
|
ff041b77cf | ||
|
|
b2d1636f8e | ||
|
|
a3e8477410 | ||
|
|
ed22b63f22 | ||
|
|
d636b29ba9 | ||
|
|
6173a2a619 | ||
|
|
fd435d2182 | ||
|
|
cb654b3f21 | ||
|
|
e8d40bbdac | ||
|
|
e5d52055d9 | ||
|
|
c5e414e6ba | ||
|
|
b9a40d1808 | ||
|
|
033dd0d15e | ||
|
|
95795d93c7 | ||
|
|
75e48b0bd6 | ||
|
|
75883a1a02 | ||
|
|
75a5546741 | ||
|
|
54071e4b87 | ||
|
|
6458a1eb62 | ||
|
|
ecf160c9bc | ||
|
|
2c98f0425d | ||
|
|
8f01988c8c | ||
|
|
b8741f6cff | ||
|
|
424751d9e9 | ||
|
|
e048a69392 | ||
|
|
2c1e846837 | ||
|
|
4b9e26de5a | ||
|
|
17f50192e7 | ||
|
|
dcdec0abb5 | ||
|
|
302c6dd02e | ||
|
|
4c56900248 | ||
|
|
1f1f853297 | ||
|
|
43f63b1b57 | ||
|
|
5ae80835b1 | ||
|
|
df3da82afd | ||
|
|
98e33b7780 | ||
|
|
8a3f6f90eb | ||
|
|
cab011def9 | ||
|
|
31c888ea8e | ||
|
|
443054428f | ||
|
|
ef9a4d5eed | ||
|
|
a2b68a3569 | ||
|
|
e7b707eb25 | ||
|
|
1786273344 | ||
|
|
d38ca803c5 | ||
|
|
a1f11cb8d9 |
@@ -1,18 +1,24 @@
|
||||
version: 2.0
|
||||
|
||||
flake8-steps: &steps
|
||||
- checkout
|
||||
- run: sudo pip install flake8
|
||||
- run: ./bin/flake8_tests.sh
|
||||
build-docker-image-job: &build-docker-image-job
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- checkout
|
||||
- run: sudo apt install python3-pip
|
||||
- run: sudo pip3 install -r requirements_bundles.txt
|
||||
- run: .circleci/update_version
|
||||
- run: npm run bundle
|
||||
- run: .circleci/docker_build
|
||||
jobs:
|
||||
python-flake8-tests:
|
||||
backend-lint:
|
||||
docker:
|
||||
- image: circleci/python:3.7.0
|
||||
steps: *steps
|
||||
legacy-python-flake8-tests:
|
||||
docker:
|
||||
- image: circleci/python:2.7.15
|
||||
steps: *steps
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo pip install flake8
|
||||
- run: ./bin/flake8_tests.sh
|
||||
backend-unit-tests:
|
||||
environment:
|
||||
COMPOSE_FILE: .circleci/docker-compose.circle.yml
|
||||
@@ -32,6 +38,9 @@ jobs:
|
||||
- run:
|
||||
name: Create Test Database
|
||||
command: docker-compose run --rm postgres psql -h postgres -U postgres -c "create database tests;"
|
||||
- run:
|
||||
name: List Enabled Query Runners
|
||||
command: docker-compose run --rm redash manage ds list_types
|
||||
- run:
|
||||
name: Run Tests
|
||||
command: docker-compose run --name tests redash tests --junitxml=junit.xml --cov-report xml --cov=redash --cov-config .coveragerc tests/
|
||||
@@ -41,6 +50,7 @@ jobs:
|
||||
mkdir -p /tmp/test-results/unit-tests
|
||||
docker cp tests:/app/coverage.xml ./coverage.xml
|
||||
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
|
||||
when: always
|
||||
- store_test_results:
|
||||
path: /tmp/test-results
|
||||
- store_artifacts:
|
||||
@@ -60,8 +70,8 @@ jobs:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: sudo pip install -r requirements_bundles.txt
|
||||
- run: sudo apt install python3-pip
|
||||
- run: sudo pip3 install -r requirements_bundles.txt
|
||||
- run: npm install
|
||||
- run: npm run bundle
|
||||
- run: npm test
|
||||
@@ -90,47 +100,25 @@ jobs:
|
||||
- run:
|
||||
name: Execute Cypress tests
|
||||
command: npm run cypress run-ci
|
||||
build-tarball:
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: sudo pip install -r requirements_bundles.txt
|
||||
- run: npm install
|
||||
- run: .circleci/update_version
|
||||
- run: npm run bundle
|
||||
- run: npm run build
|
||||
- run: rm -rf ./node_modules/
|
||||
- run: .circleci/pack
|
||||
- store_artifacts:
|
||||
path: /tmp/artifacts/
|
||||
build-docker-image:
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: sudo pip install -r requirements_bundles.txt
|
||||
- run: .circleci/update_version
|
||||
- run: npm run bundle
|
||||
- run: .circleci/docker_build
|
||||
build-docker-image: *build-docker-image-job
|
||||
build-preview-docker-image: *build-docker-image-job
|
||||
workflows:
|
||||
version: 2
|
||||
build:
|
||||
jobs:
|
||||
- python-flake8-tests
|
||||
- legacy-python-flake8-tests
|
||||
- backend-unit-tests
|
||||
- backend-lint
|
||||
- backend-unit-tests:
|
||||
requires:
|
||||
- backend-lint
|
||||
- frontend-lint
|
||||
- frontend-unit-tests:
|
||||
requires:
|
||||
- backend-lint
|
||||
- frontend-lint
|
||||
- frontend-e2e-tests:
|
||||
requires:
|
||||
- frontend-lint
|
||||
- build-tarball:
|
||||
- build-preview-docker-image:
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
@@ -139,15 +127,16 @@ workflows:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- hold:
|
||||
type: approval
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /release\/.*/
|
||||
- build-docker-image:
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- preview-image
|
||||
- /release\/.*/
|
||||
- hold
|
||||
|
||||
@@ -14,9 +14,16 @@ services:
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
REDASH_RATELIMIT_ENABLED: "false"
|
||||
worker:
|
||||
scheduler:
|
||||
build: ../
|
||||
command: scheduler
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
worker:
|
||||
build: ../
|
||||
command: worker
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
@@ -24,7 +31,18 @@ services:
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
QUEUES: "queries,scheduled_queries,celery,schemas"
|
||||
QUEUES: "default periodic schemas"
|
||||
celery_worker:
|
||||
build: ../
|
||||
command: celery_worker
|
||||
depends_on:
|
||||
- server
|
||||
environment:
|
||||
PYTHONUNBUFFERED: 0
|
||||
REDASH_LOG_LEVEL: "INFO"
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
QUEUES: "queries,scheduled_queries"
|
||||
WORKERS_COUNT: 2
|
||||
cypress:
|
||||
build:
|
||||
@@ -32,7 +50,9 @@ services:
|
||||
dockerfile: .circleci/Dockerfile.cypress
|
||||
depends_on:
|
||||
- server
|
||||
- celery_worker
|
||||
- worker
|
||||
- scheduler
|
||||
environment:
|
||||
CYPRESS_baseUrl: "http://server:5000"
|
||||
PERCY_TOKEN: ${PERCY_TOKEN}
|
||||
|
||||
113
CHANGELOG.md
113
CHANGELOG.md
@@ -1,5 +1,118 @@
|
||||
# Change Log
|
||||
|
||||
## v8.0.0-beta.2 - 2019-09-16
|
||||
|
||||
This is an update to the previous beta release, which includes:
|
||||
|
||||
* Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details).
|
||||
* Visualizations:
|
||||
- Allow the user to decide how to handle null values in charts.
|
||||
* Upgrade Sentry-SDK to latest version.
|
||||
* Make horizontal table scroll visible in dashboard widgets without scrolling.
|
||||
* Data Sources:
|
||||
* Add support for Azure Data Explorer (Kusto).
|
||||
* MySQL: fix connections without SSL configuration failing.
|
||||
* Amazon Redshift: option to set query group for adhoc/scheduled queries.
|
||||
* Hive: make error message more friendly.
|
||||
* Qubole: add support to run Quantum queries.
|
||||
* Display data source icon in query editor.
|
||||
* Fix: allow users with view only acces to use the queries in Query Results
|
||||
* Dashboard: when updating parameters refersh only widgets that use those parameters.
|
||||
|
||||
This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz.
|
||||
|
||||
|
||||
## v8.0.0-beta - 2019-08-18
|
||||
|
||||
After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements.
|
||||
|
||||
While this version is already running on the hosted platform to make sure it's stable, we're excited to put this in the hands of our Open Source users.
|
||||
|
||||
Starting from this release we will no longer build a tarball distribution of the codebase and recommend everyone to switch over to using our Docker images. We're planning on dropping Python 2 support towards its EOL this year and switching over to the Docker image will make this transition much simpler.
|
||||
|
||||
This release was made possible by contributions from over 40 people: @aidarbek, @AntonZarutsky, @ariarijp, @arikfr, @combineads, @deecay, @fmy, @gabrieldutra, @guwenqing, @guyco33, @ialeinikov, @Jakdaw, @jezdez, @justinclift, @k-tomoyasu, @katty0324, @koooge, @kravets-levko, @ktmud, @KumanoTanaka, @kyoshidajp, @nason, @oldPadavan, @openjck, @osule, @otsaloma, @ranbena, @rauchy, @rueian, @sekiyama58, @shinsuke-nara, @taminif, @The-Alchemist, @vv-p, @washort, @wudi-ayuan, @ygrishaev, @yoavbls, @yoshiken, @yusukegoto and the support of over 500 organizations who subscribed to our hosted version and by that sponsor the team's work.
|
||||
|
||||
### Parameters
|
||||
|
||||
- Parameter UI improvements:
|
||||
- Support for multi-select in dropdown (and query dropdown) parameters.
|
||||
- Support for dynamic values in date and date-range parameters.
|
||||
- Search dropdown parameter values.
|
||||
- New UX for applying parameter changes in queries and dashboards.
|
||||
- Allow using Safe Parameters in visualization embeds and public dashboards. Safe Parameters are any parameter type except for the a text parameter (dropdowns are safe).
|
||||
|
||||
### Data Sources
|
||||
|
||||
- New Data Sources: Couchbase, Phoenix and Dgraph.
|
||||
- New JSON data source (and deprecated old URL data source).
|
||||
- Snowflake: update connector to latest version.
|
||||
- PostgreSQL: show only accessible tables in schema.
|
||||
- BigQuery:
|
||||
- Correctly handle NaN values.
|
||||
- Treat repeated fields as rrays.
|
||||
- [BigQuery] Fix: in some queries there is no mode field
|
||||
- DynamoDB:
|
||||
- Support for Unicode in queries.
|
||||
- Safe loading of schema.
|
||||
- Rockset: better handling of query errors.
|
||||
- Google Sheets:
|
||||
- Support for Team Drive.
|
||||
- Friendlier error message in case of an API error and more reliable test connection.
|
||||
- MySQL:
|
||||
- Support for calling Stored Procedures and better handling of query cancellation.
|
||||
- Switch to using `mysqlclient` (a maintained fork of `Python-MySQL`).
|
||||
- MongoDB: Support serializing Decimal128 values.
|
||||
- Presto: support for passwords in connection settings.
|
||||
- Amazon Athena: allow to specify custom work group.
|
||||
- Query Results: querying a column with a dictionary or array fails
|
||||
- Clickhouse: make sure we don't show password in error messages.
|
||||
- Enable Cassandra support by default.
|
||||
|
||||
### Visualizations
|
||||
|
||||
- Charts:
|
||||
- Fix: legend overlapping chart on small screens.
|
||||
- Fix: Pie chart not rendering when series doesn't exist in options.
|
||||
- Pie Chart: add option to set direction of slices.
|
||||
- WordCloud: rewritten to support new options (provide frequency in query, limits), scale when resizing, handle long words and more.
|
||||
- Pivot Table: support hiding totals.
|
||||
- Counters: apply formatting to target value.
|
||||
- Maps:
|
||||
- Ability to customize marker icon and color.
|
||||
- Customization options for Choropleth maps.
|
||||
- New Visualization: Details View.
|
||||
|
||||
### **UX**
|
||||
|
||||
- Replace blank screen with a loading indicator when the application is doing its first load.
|
||||
- Multiple improvements to dashboards editing: auto-save, grid markings and better refresh indicator.
|
||||
- Admin can now edit user's groups from the user page.
|
||||
- Add keyboard shortcut (Ctrl/Cmd+Shift+F) to trigger query formatting.
|
||||
|
||||
### API
|
||||
|
||||
- Query Result API response minimized to only required fields when called with a non user API key.
|
||||
- Prefer API key over cookies in authentication.
|
||||
- User can now regenerate Query API Key.
|
||||
|
||||
### Other Changes
|
||||
|
||||
- Sends CSP headers to prevent various kinds of security attacks via the browser. Might break unusual usages and embeds of Redash.
|
||||
- New Failed Scheduled Queries email report (can be enabled from organization settings screen).
|
||||
- Deprecated HipChat Alert Destination.
|
||||
- Add options to hide different parts of a Visualization embed UI (parameters, title, link to query).
|
||||
- Support multi-byte search for query names and descriptions (needs to be enabled in Organization settings screen).
|
||||
- CSV query results download: correctly serialize booleans and date values.
|
||||
- Dashboard filters now collect values from all widgets with the same filter.
|
||||
- Support for custom message and description in alert notifications (currently disabled behind a feature flag until we improve the alert UX).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: adding widget to dashboard from a query page is broken.
|
||||
- Fix: default time format option was wrong.
|
||||
- Fix: when too many errors of a scheduled queries occur it causes an OverflowError.
|
||||
- Fix: when forking a query maintain the same visualizations order.
|
||||
|
||||
## v7.0.0 - 2019-03-17
|
||||
|
||||
We're trying a new format for the CHANGELOG in this release. Focusing on the bigger changes, but for whoever interested, you can see all the changes [here](https://github.com/getredash/redash/compare/v6.0.0...master).
|
||||
|
||||
31
Dockerfile
31
Dockerfile
@@ -8,11 +8,40 @@ COPY client /frontend/client
|
||||
COPY webpack.config.js /frontend/
|
||||
RUN npm run build
|
||||
|
||||
FROM redash/base:debian
|
||||
FROM python:3.7-slim
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# Controls whether to install extra dependencies needed for all data sources.
|
||||
ARG skip_ds_deps
|
||||
|
||||
RUN useradd --create-home redash
|
||||
|
||||
# Ubuntu packages
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
build-essential \
|
||||
pwgen \
|
||||
libffi-dev \
|
||||
sudo \
|
||||
git-core \
|
||||
wget \
|
||||
# Postgres client
|
||||
libpq-dev \
|
||||
# for SAML
|
||||
xmlsec1 \
|
||||
# Additional packages required for data sources:
|
||||
libssl-dev \
|
||||
default-libmysqlclient-dev \
|
||||
freetds-dev \
|
||||
libsasl2-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# We first copy only the requirements file, to avoid rebuilding on every file
|
||||
# change.
|
||||
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
[](https://redash.io/help/)
|
||||
[](https://datree.io/?src=badge)
|
||||

|
||||
[](https://circleci.com/gh/getredash/redash/tree/master)
|
||||
|
||||
**_Redash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#!/usr/bin/env python3
|
||||
"""Copy bundle extension files to the client/app/extension directory"""
|
||||
import logging
|
||||
import os
|
||||
from pathlib2 import Path
|
||||
from pathlib import Path
|
||||
from shutil import copy
|
||||
from collections import OrderedDict as odict
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
worker() {
|
||||
celery_worker() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries}
|
||||
WORKER_EXTRA_OPTIONS=${WORKER_EXTRA_OPTIONS:-}
|
||||
|
||||
echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
@@ -11,23 +11,36 @@ worker() {
|
||||
}
|
||||
|
||||
scheduler() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-1}
|
||||
QUEUES=${QUEUES:-celery}
|
||||
SCHEDULE_DB=${SCHEDULE_DB:-celerybeat-schedule}
|
||||
echo "Starting RQ scheduler..."
|
||||
|
||||
echo "Starting scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
exec /app/manage.py rq scheduler
|
||||
}
|
||||
|
||||
exec /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
|
||||
dev_scheduler() {
|
||||
echo "Starting dev RQ scheduler..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq scheduler
|
||||
}
|
||||
|
||||
worker() {
|
||||
echo "Starting RQ worker..."
|
||||
|
||||
exec /app/manage.py rq worker $QUEUES
|
||||
}
|
||||
|
||||
dev_worker() {
|
||||
echo "Starting dev RQ worker..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq worker $QUEUES
|
||||
}
|
||||
|
||||
dev_celery_worker() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
|
||||
SCHEDULE_DB=${SCHEDULE_DB:-celerybeat-schedule}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries}
|
||||
|
||||
echo "Starting dev scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- /usr/local/bin/celery worker --app=redash.worker -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
|
||||
}
|
||||
|
||||
server() {
|
||||
@@ -45,6 +58,10 @@ celery_healthcheck() {
|
||||
exec /usr/local/bin/celery inspect ping --app=redash.worker -d celery@$HOSTNAME
|
||||
}
|
||||
|
||||
rq_healthcheck() {
|
||||
exec /app/manage.py rq healthcheck
|
||||
}
|
||||
|
||||
help() {
|
||||
echo "Redash Docker."
|
||||
echo ""
|
||||
@@ -52,10 +69,14 @@ help() {
|
||||
echo ""
|
||||
|
||||
echo "server -- start Redash server (with gunicorn)"
|
||||
echo "worker -- start Celery worker"
|
||||
echo "scheduler -- start Celery worker with a beat (scheduler) process"
|
||||
echo "dev_worker -- start Celery worker with a beat (scheduler) process which picks up code changes and reloads"
|
||||
echo "celery_worker -- start Celery worker"
|
||||
echo "dev_celery_worker -- start Celery worker process which picks up code changes and reloads"
|
||||
echo "worker -- start a single RQ worker"
|
||||
echo "dev_worker -- start a single RQ worker with code reloading"
|
||||
echo "scheduler -- start an rq-scheduler instance"
|
||||
echo "dev_scheduler -- start an rq-scheduler instance with code reloading"
|
||||
echo "celery_healthcheck -- runs a Celery healthcheck. Useful for Docker's HEALTHCHECK mechanism."
|
||||
echo "rq_healthcheck -- runs a RQ healthcheck that verifies that all local workers are active. Useful for Docker's HEALTHCHECK mechanism."
|
||||
echo ""
|
||||
echo "shell -- open shell"
|
||||
echo "dev_server -- start Flask development server with debugger and auto reload"
|
||||
@@ -89,10 +110,30 @@ case "$1" in
|
||||
shift
|
||||
scheduler
|
||||
;;
|
||||
dev_scheduler)
|
||||
shift
|
||||
dev_scheduler
|
||||
;;
|
||||
celery_worker)
|
||||
shift
|
||||
celery_worker
|
||||
;;
|
||||
dev_celery_worker)
|
||||
shift
|
||||
dev_celery_worker
|
||||
;;
|
||||
dev_worker)
|
||||
shift
|
||||
dev_worker
|
||||
;;
|
||||
rq_healthcheck)
|
||||
shift
|
||||
rq_healthcheck
|
||||
;;
|
||||
celery_healthcheck)
|
||||
shift
|
||||
celery_healthcheck
|
||||
;;
|
||||
dev_server)
|
||||
export FLASK_DEBUG=1
|
||||
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -o errexit # fail the build if any task fails
|
||||
|
||||
flake8 --version ; pip --version
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/env python
|
||||
from __future__ import print_function
|
||||
#!/bin/env python3
|
||||
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_change_log(previous_sha):
|
||||
args = ['git', '--no-pager', 'log', '--merges', '--grep', 'Merge pull request', '--pretty=format:"%h|%s|%b|%p"', 'master...{}'.format(previous_sha)]
|
||||
log = subprocess.check_output(args)
|
||||
@@ -33,4 +34,4 @@ if __name__ == '__main__':
|
||||
changes = get_change_log(previous_sha)
|
||||
|
||||
for change in changes:
|
||||
print(change)
|
||||
print(change)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from __future__ import print_function
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
import urllib
|
||||
import argparse
|
||||
import os
|
||||
@@ -27,7 +27,7 @@ def run(cmd, cwd=None):
|
||||
|
||||
|
||||
def confirm(question):
|
||||
reply = str(raw_input(question + ' (y/n): ')).lower().strip()
|
||||
reply = str(input(question + ' (y/n): ')).lower().strip()
|
||||
|
||||
if reply[0] == 'y':
|
||||
return True
|
||||
|
||||
BIN
client/app/assets/images/db-logos/azure_kusto.png
Normal file
BIN
client/app/assets/images/db-logos/azure_kusto.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -47,6 +47,7 @@
|
||||
@zindex-dropdown: 2050;
|
||||
@zindex-picker: 2050;
|
||||
@zindex-tooltip: 2060;
|
||||
@item-hover-bg: #e5f8ff;
|
||||
|
||||
.@{drawer-prefix-cls} {
|
||||
&.help-drawer {
|
||||
@@ -60,6 +61,11 @@
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.ant-select-dropdown-menu-item em {
|
||||
color: @input-color-placeholder;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
// Fix for disabled button styles inside Tooltip component.
|
||||
// Tooltip wraps disabled buttons with `<span>` and moves all styles
|
||||
// and classes to that `<span>`. This resets all button styles and
|
||||
@@ -77,12 +83,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for Ant dropdowns when they are used in Boootstrap modals
|
||||
// ANGULAR_REMOVE_ME Remove when all dialogs will be migrated to React (also search and remove usages)
|
||||
.ant-dropdown-in-bootstrap-modal {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
// Button overrides
|
||||
.@{btn-prefix-cls} {
|
||||
transition-duration: 150ms;
|
||||
@@ -156,6 +156,10 @@
|
||||
border-color: transparent;
|
||||
color: @pagination-color;
|
||||
line-height: @pagination-item-size - 2px;
|
||||
|
||||
.@{pagination-prefix-cls}.mini & {
|
||||
line-height: @pagination-item-size-sm - 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus .@{pagination-prefix-cls}-item-link,
|
||||
@@ -322,7 +326,7 @@
|
||||
}
|
||||
|
||||
.@{btn-prefix-cls} .@{iconfont-css-prefix}-ellipsis {
|
||||
margin: 0 -7px;
|
||||
margin: 0 -7px 0 -8px;
|
||||
}
|
||||
|
||||
// Collapse
|
||||
@@ -364,4 +368,10 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// for form items that contain text
|
||||
&.form-item-line-height-normal .@{form-prefix-cls}-item-control {
|
||||
line-height: 20px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,53 @@
|
||||
.alert {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
.alert-page h3 {
|
||||
flex-grow: 1;
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
input {
|
||||
margin: -0.2em 0;
|
||||
width: 100%;
|
||||
min-width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-dismissable,
|
||||
.alert-dismissible {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.alert-inverse {
|
||||
.alert-variant(@alert-inverse-bg; @alert-inverse-border; @alert-inverse-text);
|
||||
.btn-create-alert[disabled] {
|
||||
display: block;
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.alert-link {
|
||||
color: #fff !important;
|
||||
font-weight: normal !important;
|
||||
text-decoration: underline;
|
||||
.alert-state {
|
||||
border-bottom: 1px solid @input-border;
|
||||
padding-bottom: 30px;
|
||||
|
||||
.alert-state-indicator {
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.alert-last-triggered {
|
||||
color: @headings-color;
|
||||
}
|
||||
}
|
||||
|
||||
.growl-animated {
|
||||
&.alert-inverse {
|
||||
box-shadow: 0 0 5px fade(@alert-inverse-bg, 50%);
|
||||
}
|
||||
|
||||
&.alert-info {
|
||||
box-shadow: 0 0 5px fade(@alert-info-bg, 50%);
|
||||
}
|
||||
.alert-query-selector {
|
||||
min-width: 250px;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
&.alert-success {
|
||||
box-shadow: 0 0 5px fade(@alert-success-bg, 50%);
|
||||
}
|
||||
// allow form item labels to gracefully break line
|
||||
.alert-form-item label {
|
||||
white-space: initial;
|
||||
padding-right: 8px;
|
||||
line-height: 21px;
|
||||
|
||||
&.alert-warning {
|
||||
box-shadow: 0 0 5px fade(@alert-warning-bg, 50%);
|
||||
}
|
||||
|
||||
&.alert-danger {
|
||||
box-shadow: 0 0 5px fade(@alert-danger-bg, 50%);
|
||||
&::after {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-right: -15px;
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
-----------------------------------------------------------*/
|
||||
@input-height-base: 35px;
|
||||
@input-color: #595959;
|
||||
@input-color-placeholder: #b4b4b4;
|
||||
@border-radius-base: 2px;
|
||||
@border-color-base: #E8E8E8;
|
||||
|
||||
|
||||
@@ -19,11 +19,22 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: @header-height;
|
||||
padding-top: 0;
|
||||
background: #F6F8F9;
|
||||
font-family: @redash-font;
|
||||
position: relative;
|
||||
|
||||
app-view {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
&.headless {
|
||||
padding-top: 0;
|
||||
.nav.app-header {
|
||||
app-view {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.app-header-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -72,10 +83,34 @@ strong {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed width layout for specific pages
|
||||
@media (min-width: 768px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 750px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 970px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 1170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbox {
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
|
||||
}
|
||||
|
||||
.clickable {
|
||||
@@ -95,3 +130,154 @@ strong {
|
||||
resize: both !important;
|
||||
transition: height 0s, width 0s !important;
|
||||
}
|
||||
|
||||
// Ace Editor
|
||||
.ace_editor {
|
||||
border: 1px solid fade(@redash-gray, 15%) !important;
|
||||
}
|
||||
|
||||
.ace-tm {
|
||||
.ace_gutter {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ace_gutter-active-line {
|
||||
background-color: fade(@redash-gray, 20%) !important;
|
||||
}
|
||||
|
||||
.ace_marker-layer .ace_active-line {
|
||||
background: fade(@redash-gray, 9%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-ace {
|
||||
background-color: fade(@redash-gray, 12%) !important;
|
||||
}
|
||||
|
||||
// resizeable
|
||||
.rg-top span, .rg-bottom span {
|
||||
height: 3px;
|
||||
border-color: #b1c1ce; // TODO: variable
|
||||
}
|
||||
|
||||
.rg-bottom {
|
||||
bottom: 15px;
|
||||
|
||||
span {
|
||||
margin: 1.5px 0 0 -10px;
|
||||
}
|
||||
}
|
||||
|
||||
// Plotly
|
||||
text.slicetext {
|
||||
text-shadow: 1px 1px 5px #333;
|
||||
}
|
||||
|
||||
// markdown
|
||||
.markdown strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.profile__image--sidebar {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.profile__image--settings {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.profile__image_thumb {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
// Error state
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
margin-top: 25vh;
|
||||
padding: 35px;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
|
||||
.error-state__icon {
|
||||
.zmdi {
|
||||
font-size: 64px;
|
||||
color: @redash-gray;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-icon-danger {
|
||||
color: @red !important;
|
||||
}
|
||||
|
||||
// page
|
||||
.page-header--new .btn-favourite, .page-header--new .btn-archive {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
display: block;
|
||||
|
||||
favorites-control {
|
||||
float: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
width: 100%;
|
||||
margin-bottom: 5px !important;
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-wrapper, .page-header--new {
|
||||
h3 {
|
||||
margin: 0.2em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.select-option-divider {
|
||||
margin: 10px 0 !important;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
.collapsing,
|
||||
.collapse.in {
|
||||
padding: 5px 10px;
|
||||
padding: 0;
|
||||
transition: all 0.35s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,3 +122,21 @@
|
||||
top: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.btn-default {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
}
|
||||
|
||||
.btn-transparent {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-default:hover, .btn-default:focus, .btn-default.focus, .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default {
|
||||
background-color: fade(@redash-gray, 25%);
|
||||
}
|
||||
|
||||
.btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus {
|
||||
color: #333;
|
||||
background-color: fade(@redash-gray, 45%);
|
||||
}
|
||||
@@ -55,14 +55,17 @@ textarea.v-resizable {
|
||||
.transition-duration(300ms);
|
||||
resize: none;
|
||||
box-shadow: 0 0 0 40px rgba(0, 0, 0, 0) !important;
|
||||
border-radius: 0;
|
||||
border-radius: @redash-input-radius;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 1px -2px rgba(121,194,255,0.5) !important;
|
||||
box-shadow: none !important;
|
||||
border-color: @blue;
|
||||
}
|
||||
&:hover {
|
||||
border-color: @blue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Custom Checkbox + Radio
|
||||
-----------------------------------------------------------*/
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
|
||||
.font-size(20, 8px, 8);
|
||||
|
||||
.f-inherit { font-size: inherit !important; }
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Font Weight
|
||||
@@ -153,4 +155,10 @@
|
||||
/* --------------------------------------------------------
|
||||
Border Radius
|
||||
-----------------------------------------------------------*/
|
||||
.brd-2 { border-radius: 2px; }
|
||||
.brd-2 { border-radius: 2px; }
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Alignment
|
||||
-----------------------------------------------------------*/
|
||||
.va-top { vertical-align: top; }
|
||||
@@ -1,14 +1,37 @@
|
||||
.label {
|
||||
border-radius: 1px;
|
||||
padding: 4px 5px 3px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
.label {
|
||||
border-radius: 2px;
|
||||
}
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px 4px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.label-default {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-archived {
|
||||
.label-warning();
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished,
|
||||
.label-tag-archived,
|
||||
.label-tag {
|
||||
margin-right: 3px;
|
||||
display: inline;
|
||||
margin-top: 2px;
|
||||
max-width: 24ch;
|
||||
.text-overflow();
|
||||
}
|
||||
@@ -31,6 +31,17 @@ tags-list {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
.badge-light {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.max-character {
|
||||
.text-overflow();
|
||||
}
|
||||
@@ -45,6 +56,11 @@ tags-list {
|
||||
line-height: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.active, &.active:hover, &.active:focus {
|
||||
background-color: #fff;
|
||||
box-shadow: inset 3px 0px 0px @brand-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item-heading {
|
||||
@@ -76,3 +92,18 @@ tags-list {
|
||||
height: 38px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ui-select-choices-row.disabled > span {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
.list-group-item.inactive,
|
||||
.ui-select-choices-row.disabled {
|
||||
background-color: #eee !important;
|
||||
border-color: transparent;
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
color: #333;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
a.navbar-brand {
|
||||
padding: 5px 5px 0px 0px;
|
||||
}
|
||||
|
||||
.navbar .fa {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.navbar .collapse.in {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
a.navbar-brand img {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
#logout {
|
||||
color: white;
|
||||
position: relative;
|
||||
left: -9px;
|
||||
bottom: -11px;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.pagination {
|
||||
border-radius: 0;
|
||||
|
||||
& > li {
|
||||
margin: 0 2px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
& > a,
|
||||
& > span {
|
||||
border-radius: 50% !important;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
|
||||
& > .zmdi {
|
||||
font-size: 22px;
|
||||
line-height: 39px;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.opacity(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Listview Pagination
|
||||
-----------------------------------------------------------*/
|
||||
.lv-pagination {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Pager
|
||||
-----------------------------------------------------------*/
|
||||
.pager li > a, .pager li > span {
|
||||
padding: 5px 10px 6px;
|
||||
color: @pagination-color;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.popover {
|
||||
box-shadow: 0 2px 30px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px;
|
||||
}
|
||||
|
||||
.popover-title {
|
||||
|
||||
@@ -132,9 +132,15 @@
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
margin-bottom: 0px;
|
||||
|
||||
> li.rd-tab-btn {
|
||||
float: right;
|
||||
padding-right: 10px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
> li > a {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
|
||||
|
||||
th.sortable-column {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
&:not(.table-striped) > thead > tr > th {
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
|
||||
[class*="bg-"] {
|
||||
& > tr > th {
|
||||
color: #fff;
|
||||
border-bottom: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
|
||||
& + tbody > tr:first-child > td {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
& > thead > tr > th {
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
@@ -29,24 +29,24 @@
|
||||
text-transform: uppercase;
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
|
||||
& > thead > tr,
|
||||
& > tbody > tr,
|
||||
& > tfoot > tr {
|
||||
|
||||
|
||||
& > th, & > td {
|
||||
|
||||
|
||||
&:first-child {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
|
||||
&:last-child {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tbody > tr:last-child > td {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
@@ -54,21 +54,21 @@
|
||||
|
||||
.table-bordered {
|
||||
border: 0;
|
||||
|
||||
|
||||
& > tbody > tr {
|
||||
& > td, & > th {
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
& > thead > tr > th {
|
||||
border-left: 0;
|
||||
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
@@ -86,14 +86,64 @@
|
||||
}
|
||||
|
||||
.tile .table {
|
||||
|
||||
|
||||
& > thead:not([class*="bg-"]) > tr > th {
|
||||
border-top: 1px solid @table-border-color;
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.table-hover > tbody > tr:hover {
|
||||
background-color: #f4f4f4;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.table-data {
|
||||
tbody > tr > td {
|
||||
padding-top: 5px !important;
|
||||
}
|
||||
|
||||
.btn-favourite, .btn-archive {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-main-title {
|
||||
font-weight: 500;
|
||||
line-height: 1.7 !important;
|
||||
}
|
||||
|
||||
.btn-favourite {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @yellow-darker;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fa-star {
|
||||
color: @yellow-darker;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-archive {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @gray-light;
|
||||
}
|
||||
|
||||
.fa-archive {
|
||||
color: @gray-light;
|
||||
}
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.table-data .label-tag {
|
||||
display: inline-block;
|
||||
max-width: 135px;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
background-color: #fff;
|
||||
margin-bottom: @grid-gutter-width;
|
||||
position: relative;
|
||||
box-shadow: @tile-shadow;
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
&[class*="bg-"] {
|
||||
color: #fff;
|
||||
@@ -12,6 +13,10 @@
|
||||
margin-bottom: @grid-gutter-width/2;
|
||||
}
|
||||
}
|
||||
.tiled {
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
}
|
||||
|
||||
.t-header {
|
||||
.th-title {
|
||||
@@ -74,6 +79,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.t-header:not(.th-alt) {
|
||||
padding: 15px;
|
||||
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
line-height: 2.2;
|
||||
}
|
||||
}
|
||||
|
||||
.tb-padding {
|
||||
padding: 20px 23px 30px;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
@logo-height: @header-height;
|
||||
@boxed-width: 1170px;
|
||||
@body-bg: #edecec;
|
||||
@spacing: 15px;
|
||||
@redash-radius: 3px;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
@@ -39,6 +41,7 @@
|
||||
-----------------------------------------------------------*/
|
||||
@font-icon: 'Material-Design-Iconic-Font';
|
||||
@font-family-sans-serif: 'Roboto', sans-serif;
|
||||
@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
@font-size-base: 13px;
|
||||
|
||||
|
||||
@@ -59,6 +62,7 @@
|
||||
@input-border: #e8e8e8;
|
||||
@input-border-radius: 0;
|
||||
@input-border-radius-large: 0px;
|
||||
@redash-input-radius: 2px;
|
||||
@input-height-large: 40px;
|
||||
@input-height-base: 35px;
|
||||
@input-height-small: 30px;
|
||||
@@ -94,6 +98,11 @@
|
||||
@gray-light: #828282;
|
||||
@ace: #f8f8f8;
|
||||
|
||||
@redash-gray: rgba(102, 136, 153, 1);
|
||||
@redash-orange: rgba(255, 120, 100, 1);
|
||||
@redash-black: rgba(0, 0, 0, 1);
|
||||
@redash-yellow: rgba(252, 252, 161, 0.75);
|
||||
|
||||
/** Form States **/
|
||||
@state-success-text: @green;
|
||||
@state-info-text: @blue;
|
||||
@@ -192,7 +201,6 @@
|
||||
@pagination-hover-color: #333;
|
||||
@pagination-hover-bg: #d7d7d7;
|
||||
@pagination-hover-border: @pagination-border;
|
||||
@pager-border-radius: 5px;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
counter-renderer {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 15px 10px;
|
||||
overflow: hidden;
|
||||
|
||||
counter {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 80px;
|
||||
line-height: normal;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
value,
|
||||
counter-target {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
counter-name {
|
||||
font-size: 0.5em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.positive value {
|
||||
color: #5cb85c;
|
||||
}
|
||||
|
||||
&.negative value {
|
||||
color: #d9534f;
|
||||
}
|
||||
}
|
||||
|
||||
counter-target {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
counter-name {
|
||||
font-size: 0.5em;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
visualization-renderer {
|
||||
.visualization-renderer {
|
||||
display: block;
|
||||
|
||||
.pagination,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
.pivot-table-visualization-container > table,
|
||||
.visualization-renderer > .visualization-renderer-wrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
@import 'inc/progress-bar';
|
||||
@import 'inc/widgets';
|
||||
@import 'inc/table';
|
||||
@import 'inc/pagination';
|
||||
@import 'inc/alert';
|
||||
@import 'inc/media';
|
||||
@import 'inc/modal';
|
||||
@@ -45,7 +44,6 @@
|
||||
@import 'inc/profile';
|
||||
@import 'inc/404';
|
||||
@import 'inc/ie-warning';
|
||||
@import 'inc/navbar';
|
||||
@import 'inc/edit-in-place';
|
||||
@import 'inc/growl';
|
||||
@import 'inc/flex';
|
||||
@@ -54,12 +52,8 @@
|
||||
@import 'inc/schema-browser';
|
||||
@import 'inc/toast';
|
||||
@import 'inc/visualizations/box';
|
||||
@import 'inc/visualizations/counter-render';
|
||||
@import 'inc/visualizations/sankey';
|
||||
@import 'inc/visualizations/pivot-table';
|
||||
@import 'inc/visualizations/map';
|
||||
@import 'inc/visualizations/chart';
|
||||
@import 'inc/visualizations/sunburst';
|
||||
@import 'inc/visualizations/cohort';
|
||||
@import 'inc/visualizations/misc';
|
||||
|
||||
@@ -71,11 +65,11 @@
|
||||
@import 'inc/vendor-overrides/ui-select';
|
||||
|
||||
/** REDASH STYLING **/
|
||||
@import 'redash/redash-newstyle';
|
||||
@import 'redash/redash-table';
|
||||
@import 'redash/query';
|
||||
@import 'redash/tags-control';
|
||||
@import 'redash/css-logo';
|
||||
@import 'redash/loading-indicator';
|
||||
|
||||
|
||||
|
||||
|
||||
51
client/app/assets/less/redash/loading-indicator.less
Normal file
51
client/app/assets/less/redash/loading-indicator.less
Normal file
@@ -0,0 +1,51 @@
|
||||
.loading-indicator {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -50px 0 0 -50px; // center
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: linear;
|
||||
transition-property: opacity, transform;
|
||||
|
||||
#css-logo {
|
||||
animation: hover 2s infinite;
|
||||
}
|
||||
|
||||
#shadow {
|
||||
width: 33px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: black;
|
||||
opacity: 0.25;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 34px;
|
||||
top: 115px;
|
||||
animation: shadow 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes hover {
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
@keyframes shadow {
|
||||
50% {
|
||||
transform: scaleX(0.9);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hide indicator when app-view has content
|
||||
app-view:not(:empty) ~ .loading-indicator {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
|
||||
* {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ body.fixed-layout {
|
||||
app-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@@ -92,7 +93,7 @@ edit-in-place p.editable:hover {
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_completion-highlight {
|
||||
@@ -208,18 +209,18 @@ edit-in-place p.editable:hover {
|
||||
}
|
||||
}
|
||||
|
||||
.visualization-renderer {
|
||||
.pagination,
|
||||
.ant-pagination {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.embed__vis {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.embed-heading {
|
||||
h3 {
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.widget-wrapper {
|
||||
.body-container {
|
||||
.filters-wrapper {
|
||||
@@ -343,7 +344,8 @@ a.label-tag {
|
||||
border-bottom: 1px solid #efefef;
|
||||
}
|
||||
|
||||
.pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
.pivot-table-visualization-container > table,
|
||||
.visualization-renderer > .visualization-renderer-wrapper {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -676,8 +678,17 @@ nav .rg-bottom {
|
||||
.filter-container {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-edit-visualisation {
|
||||
// Responsive fixes
|
||||
@media (max-width: 767px) {
|
||||
.query-page-wrapper {
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,6 @@
|
||||
@import 'inc/ie-warning';
|
||||
@import 'inc/flex';
|
||||
|
||||
@import 'redash/redash-newstyle';
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
|
||||
83
client/app/components/BeaconConsent.jsx
Normal file
83
client/app/components/BeaconConsent.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Card from 'antd/lib/card';
|
||||
import Button from 'antd/lib/button';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import HelpTrigger from '@/components/HelpTrigger';
|
||||
import DynamicComponent from '@/components/DynamicComponent';
|
||||
import OrgSettings from '@/services/organizationSettings';
|
||||
|
||||
const Text = Typography.Text;
|
||||
|
||||
export function BeaconConsent() {
|
||||
const [hide, setHide] = useState(false);
|
||||
|
||||
if (!clientConfig.showBeaconConsentMessage || hide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hideConsentCard = () => {
|
||||
clientConfig.showBeaconConsentMessage = false;
|
||||
setHide(true);
|
||||
};
|
||||
|
||||
const confirmConsent = (confirm) => {
|
||||
let message = '🙏 Thank you.';
|
||||
|
||||
if (!confirm) {
|
||||
message = 'Settings Saved.';
|
||||
}
|
||||
|
||||
OrgSettings.save({ beacon_consent: confirm }, message)
|
||||
// .then(() => {
|
||||
// // const settings = get(response, 'settings');
|
||||
// // this.setState({ settings, formValues: { ...settings } });
|
||||
// })
|
||||
.finally(hideConsentCard);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicComponent name="BeaconConsent">
|
||||
<div className="m-t-10 tiled">
|
||||
<Card
|
||||
title={(
|
||||
<>
|
||||
Would you be ok with sharing anonymous usage data with the Redash team?{' '}
|
||||
<HelpTrigger type="USAGE_DATA_SHARING" />
|
||||
</>
|
||||
)}
|
||||
bordered={false}
|
||||
>
|
||||
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
||||
<div className="m-t-5">
|
||||
<ul>
|
||||
<li> Number of users, queries, dashboards, alerts, widgets and visualizations.</li>
|
||||
<li> Types of data sources, alert destinations and visualizations.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Text>All data is aggregated and will never include any sensitive or private data.</Text>
|
||||
<div className="m-t-5">
|
||||
<Button type="primary" className="m-r-5" onClick={() => confirmConsent(true)}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="default" onClick={() => confirmConsent(false)}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
<div className="m-t-15">
|
||||
<Text type="secondary">
|
||||
You can change this setting anytime from the <a href="settings/organization">Organization Settings</a> page.
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('beaconConsent', react2angular(BeaconConsent));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -1,23 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
// ANGULAR_REMOVE_ME
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
|
||||
import './color-box.less';
|
||||
|
||||
export function ColorBox({ color }) {
|
||||
return <span style={{ backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
ColorBox.propTypes = {
|
||||
color: PropTypes.string,
|
||||
};
|
||||
|
||||
ColorBox.defaultProps = {
|
||||
color: 'transparent',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('colorBox', react2angular(ColorBox));
|
||||
ngModule.component('colorBox', react2angular(ColorPicker.Swatch));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
93
client/app/components/ColorPicker/Input.jsx
Normal file
93
client/app/components/ColorPicker/Input.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { isNil, isArray, chunk, map, filter, toPairs } from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import TextInput from 'antd/lib/input';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import Swatch from './Swatch';
|
||||
|
||||
import './input.less';
|
||||
|
||||
function preparePresets(presetColors, presetColumns) {
|
||||
presetColors = isArray(presetColors) ? map(presetColors, v => [null, v]) : toPairs(presetColors);
|
||||
presetColors = map(presetColors, ([title, value]) => {
|
||||
if (isNil(value)) {
|
||||
return [title, null];
|
||||
}
|
||||
value = tinycolor(value);
|
||||
if (value.isValid()) {
|
||||
return [title, '#' + value.toHex().toUpperCase()];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return chunk(filter(presetColors), presetColumns);
|
||||
}
|
||||
|
||||
function validateColor(value, callback, prefix = '#') {
|
||||
if (isNil(value)) {
|
||||
callback(null);
|
||||
}
|
||||
value = tinycolor(value);
|
||||
if (value.isValid()) {
|
||||
callback(prefix + value.toHex().toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
export default function Input({ color, presetColors, presetColumns, onChange, onPressEnter }) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const presets = preparePresets(presetColors, presetColumns);
|
||||
|
||||
function handleInputChange(value) {
|
||||
setInputValue(value);
|
||||
validateColor(value, onChange);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInputFocused) {
|
||||
validateColor(color, setInputValue, '');
|
||||
}
|
||||
}, [color, isInputFocused]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{map(presets, (group, index) => (
|
||||
<div className="color-picker-input-swatches" key={`preset-row-${index}`}>
|
||||
{map(group, ([title, value]) => (
|
||||
<Swatch key={value} color={value} title={title} size={30} onClick={() => validateColor(value, onChange)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="color-picker-input">
|
||||
<TextInput
|
||||
addonBefore={<Typography.Text type="secondary">#</Typography.Text>}
|
||||
value={inputValue}
|
||||
onChange={e => handleInputChange(e.target.value)}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onPressEnter={onPressEnter}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Input.propTypes = {
|
||||
color: PropTypes.string,
|
||||
presetColors: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips)
|
||||
PropTypes.objectOf(PropTypes.string), // color name => color value
|
||||
]),
|
||||
presetColumns: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
onPressEnter: PropTypes.func,
|
||||
};
|
||||
|
||||
Input.defaultProps = {
|
||||
color: '#FFFFFF',
|
||||
presetColors: null,
|
||||
presetColumns: 8,
|
||||
onChange: () => {},
|
||||
onPressEnter: () => {},
|
||||
};
|
||||
37
client/app/components/ColorPicker/Swatch.jsx
Normal file
37
client/app/components/ColorPicker/Swatch.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { isString } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
|
||||
import './swatch.less';
|
||||
|
||||
export default function Swatch({ className, color, title, size, ...props }) {
|
||||
const result = (
|
||||
<span
|
||||
className={`color-swatch ${className}`}
|
||||
style={{ backgroundColor: color, width: size }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isString(title) && (title !== '')) {
|
||||
return (
|
||||
<Tooltip title={title} mouseEnterDelay={0} mouseLeaveDelay={0}>{result}</Tooltip>
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Swatch.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
};
|
||||
|
||||
Swatch.defaultProps = {
|
||||
className: '',
|
||||
title: null,
|
||||
color: 'transparent',
|
||||
size: 12,
|
||||
};
|
||||
128
client/app/components/ColorPicker/index.jsx
Normal file
128
client/app/components/ColorPicker/index.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { toString } from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import Popover from 'antd/lib/popover';
|
||||
import Card from 'antd/lib/card';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import Icon from 'antd/lib/icon';
|
||||
|
||||
import ColorInput from './Input';
|
||||
import Swatch from './Swatch';
|
||||
|
||||
import './index.less';
|
||||
|
||||
function validateColor(value, fallback = null) {
|
||||
value = tinycolor(value);
|
||||
return value.isValid() ? '#' + value.toHex().toUpperCase() : fallback;
|
||||
}
|
||||
|
||||
export default function ColorPicker({
|
||||
color, placement, presetColors, presetColumns, triggerSize, interactive, children, onChange,
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [currentColor, setCurrentColor] = useState('');
|
||||
|
||||
function handleApply() {
|
||||
setVisible(false);
|
||||
if (!interactive) {
|
||||
onChange(currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
if (!interactive) {
|
||||
actions.push((
|
||||
<Tooltip key="cancel" title="Cancel">
|
||||
<Icon type="close" onClick={handleCancel} />
|
||||
</Tooltip>
|
||||
));
|
||||
actions.push((
|
||||
<Tooltip key="apply" title="Apply">
|
||||
<Icon type="check" onClick={handleApply} />
|
||||
</Tooltip>
|
||||
));
|
||||
}
|
||||
|
||||
function handleInputChange(newColor) {
|
||||
setCurrentColor(newColor);
|
||||
if (interactive) {
|
||||
onChange(newColor);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentColor(validateColor(color));
|
||||
}
|
||||
}, [color, visible]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
overlayClassName={`color-picker ${interactive ? 'color-picker-interactive' : 'color-picker-with-actions'}`}
|
||||
overlayStyle={{ '--color-picker-selected-color': currentColor }}
|
||||
content={(
|
||||
<Card
|
||||
className="color-picker-panel"
|
||||
bordered={false}
|
||||
title={toString(currentColor).toUpperCase()}
|
||||
headStyle={{
|
||||
backgroundColor: currentColor,
|
||||
color: tinycolor(currentColor).isLight() ? '#000000' : '#ffffff',
|
||||
}}
|
||||
actions={actions}
|
||||
>
|
||||
<ColorInput
|
||||
color={currentColor}
|
||||
presetColors={presetColors}
|
||||
presetColumns={presetColumns}
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleApply}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
trigger="click"
|
||||
placement={placement}
|
||||
visible={visible}
|
||||
onVisibleChange={setVisible}
|
||||
>
|
||||
{children || (<Swatch className="color-picker-trigger" color={validateColor(color)} size={triggerSize} />)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
ColorPicker.propTypes = {
|
||||
color: PropTypes.string,
|
||||
placement: PropTypes.oneOf([
|
||||
'top', 'left', 'right', 'bottom',
|
||||
'topLeft', 'topRight', 'bottomLeft', 'bottomRight',
|
||||
'leftTop', 'leftBottom', 'rightTop', 'rightBottom',
|
||||
]),
|
||||
presetColors: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips)
|
||||
PropTypes.objectOf(PropTypes.string), // color name => color value
|
||||
]),
|
||||
presetColumns: PropTypes.number,
|
||||
triggerSize: PropTypes.number,
|
||||
interactive: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
ColorPicker.defaultProps = {
|
||||
color: '#FFFFFF',
|
||||
placement: 'top',
|
||||
presetColors: null,
|
||||
presetColumns: 8,
|
||||
triggerSize: 30,
|
||||
interactive: false,
|
||||
children: null,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
ColorPicker.Input = ColorInput;
|
||||
ColorPicker.Swatch = Swatch;
|
||||
40
client/app/components/ColorPicker/index.less
Normal file
40
client/app/components/ColorPicker/index.less
Normal file
@@ -0,0 +1,40 @@
|
||||
.color-picker {
|
||||
&.color-picker-with-actions {
|
||||
&.ant-popover-placement-top,
|
||||
&.ant-popover-placement-topLeft,
|
||||
&.ant-popover-placement-topRight,
|
||||
&.ant-popover-placement-leftBottom,
|
||||
&.ant-popover-placement-rightBottom {
|
||||
> .ant-popover-content > .ant-popover-arrow {
|
||||
border-color: #fafafa; // same as card actions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-popover-placement-bottom,
|
||||
&.ant-popover-placement-bottomLeft,
|
||||
&.ant-popover-placement-bottomRight,
|
||||
&.ant-popover-placement-leftTop,
|
||||
&.ant-popover-placement-rightTop {
|
||||
> .ant-popover-content > .ant-popover-arrow {
|
||||
border-color: var(--color-picker-selected-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
text-align: center;
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
19
client/app/components/ColorPicker/input.less
Normal file
19
client/app/components/ColorPicker/input.less
Normal file
@@ -0,0 +1,19 @@
|
||||
.color-picker-input-swatches {
|
||||
margin: 0 0 10px 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
.color-swatch {
|
||||
cursor: pointer;
|
||||
margin: 0 10px 0 0;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
30
client/app/components/ColorPicker/swatch.less
Normal file
30
client/app/components/ColorPicker/swatch.less
Normal file
@@ -0,0 +1,30 @@
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
width: 12px;
|
||||
|
||||
@cell-size: 12px;
|
||||
@cell-color: rgba(0, 0, 0, 0.1);
|
||||
|
||||
background-color: transparent;
|
||||
background-image:
|
||||
linear-gradient(45deg, @cell-color 25%, transparent 25%),
|
||||
linear-gradient(-45deg, @cell-color 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, @cell-color 75%),
|
||||
linear-gradient(-45deg, transparent 75%, @cell-color 75%);
|
||||
background-size: @cell-size @cell-size;
|
||||
background-position: 0 0, 0 @cell-size/2, @cell-size/2 -@cell-size/2, -@cell-size/2 0px;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: ~"calc(100% - 2px)";
|
||||
background-color: inherit;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { PreviewCard } from '@/components/PreviewCard';
|
||||
import EmptyState from '@/components/items-list/components/EmptyState';
|
||||
import DynamicForm from '@/components/dynamic-form/DynamicForm';
|
||||
import helper from '@/components/dynamic-form/dynamicFormHelper';
|
||||
import { HelpTrigger, TYPES as HELP_TRIGGER_TYPES } from '@/components/HelpTrigger';
|
||||
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from '@/components/HelpTrigger';
|
||||
|
||||
const { Step } = Steps;
|
||||
const { Search } = Input;
|
||||
@@ -100,7 +100,7 @@ class CreateSourceDialog extends React.Component {
|
||||
const fields = helper.getFields(selectedType);
|
||||
const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`;
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<img
|
||||
className="p-5"
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
|
||||
import { includes, words, capitalize, clone, isNull } from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Checkbox from 'antd/lib/checkbox';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Form from 'antd/lib/form';
|
||||
import Button from 'antd/lib/button';
|
||||
import Select from 'antd/lib/select';
|
||||
import Input from 'antd/lib/input';
|
||||
import Divider from 'antd/lib/divider';
|
||||
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
|
||||
import { QuerySelector } from '@/components/QuerySelector';
|
||||
import { Query } from '@/services/query';
|
||||
import { includes, words, capitalize, clone, isNull } from "lodash";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Checkbox from "antd/lib/checkbox";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Form from "antd/lib/form";
|
||||
import Button from "antd/lib/button";
|
||||
import Select from "antd/lib/select";
|
||||
import Input from "antd/lib/input";
|
||||
import Divider from "antd/lib/divider";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import { QuerySelector } from "@/components/QuerySelector";
|
||||
import { Query } from "@/services/query";
|
||||
|
||||
const { Option } = Select;
|
||||
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
|
||||
|
||||
function getDefaultTitle(text) {
|
||||
return capitalize(words(text).join(' ')); // humanize
|
||||
return capitalize(words(text).join(" ")); // humanize
|
||||
}
|
||||
|
||||
function isTypeDateRange(type) {
|
||||
@@ -26,28 +25,28 @@ function isTypeDateRange(type) {
|
||||
|
||||
function joinExampleList(multiValuesOptions) {
|
||||
const { prefix, suffix } = multiValuesOptions;
|
||||
return ['value1', 'value2', 'value3']
|
||||
.map(value => `${prefix}${value}${suffix}`)
|
||||
.join(',');
|
||||
return ["value1", "value2", "value3"]
|
||||
.map((value) => `${prefix}${value}${suffix}`)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
||||
let helpText = '';
|
||||
let validateStatus = '';
|
||||
let helpText = "";
|
||||
let validateStatus = "";
|
||||
|
||||
if (!name) {
|
||||
helpText = 'Choose a keyword for this parameter';
|
||||
helpText = "Choose a keyword for this parameter";
|
||||
setValidation(false);
|
||||
} else if (includes(existingNames, name)) {
|
||||
helpText = 'Parameter with this name already exists';
|
||||
helpText = "Parameter with this name already exists";
|
||||
setValidation(false);
|
||||
validateStatus = 'error';
|
||||
validateStatus = "error";
|
||||
} else {
|
||||
if (isTypeDateRange(type)) {
|
||||
helpText = (
|
||||
<React.Fragment>
|
||||
Appears in query as {' '}
|
||||
<code style={{ display: 'inline-block', color: 'inherit' }}>
|
||||
Appears in query as{" "}
|
||||
<code style={{ display: "inline-block", color: "inherit" }}>
|
||||
{`{{${name}.start}} {{${name}.end}}`}
|
||||
</code>
|
||||
</React.Fragment>
|
||||
@@ -64,7 +63,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
||||
validateStatus={validateStatus}
|
||||
{...formItemProps}
|
||||
>
|
||||
<Input onChange={e => onChange(e.target.value)} autoFocus />
|
||||
<Input onChange={(e) => onChange(e.target.value)} autoFocus />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
@@ -101,12 +100,12 @@ function EditParameterSettingsDialog(props) {
|
||||
}
|
||||
|
||||
// title
|
||||
if (param.title === '') {
|
||||
if (param.title === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// query
|
||||
if (param.type === 'query' && !param.queryId) {
|
||||
if (param.type === "query" && !param.queryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -129,21 +128,29 @@ function EditParameterSettingsDialog(props) {
|
||||
return (
|
||||
<Modal
|
||||
{...props.dialog.props}
|
||||
title={isNew ? 'Add Parameter' : param.name}
|
||||
title={isNew ? "Add Parameter" : param.name}
|
||||
width={600}
|
||||
footer={[(
|
||||
<Button key="cancel" onClick={props.dialog.dismiss}>Cancel</Button>
|
||||
), (
|
||||
<Button key="submit" htmlType="submit" disabled={!isFulfilled()} type="primary" form="paramForm" data-test="SaveParameterSettings">
|
||||
{isNew ? 'Add Parameter' : 'OK'}
|
||||
</Button>
|
||||
)]}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={props.dialog.dismiss}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
htmlType="submit"
|
||||
disabled={!isFulfilled()}
|
||||
type="primary"
|
||||
form="paramForm"
|
||||
data-test="SaveParameterSettings"
|
||||
>
|
||||
{isNew ? "Add Parameter" : "OK"}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
|
||||
{isNew && (
|
||||
<NameInput
|
||||
name={param.name}
|
||||
onChange={name => setParam({ ...param, name })}
|
||||
onChange={(name) => setParam({ ...param, name })}
|
||||
setValidation={setIsNameValid}
|
||||
existingNames={props.existingParams}
|
||||
type={param.type}
|
||||
@@ -151,90 +158,144 @@ function EditParameterSettingsDialog(props) {
|
||||
)}
|
||||
<Form.Item label="Title" {...formItemProps}>
|
||||
<Input
|
||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
||||
value={
|
||||
isNull(param.title) ? getDefaultTitle(param.name) : param.title
|
||||
}
|
||||
onChange={(e) => setParam({ ...param, title: e.target.value })}
|
||||
data-test="ParameterTitleInput"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Type" {...formItemProps}>
|
||||
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect">
|
||||
<Option value="text" data-test="TextParameterTypeOption">Text</Option>
|
||||
<Option value="number" data-test="NumberParameterTypeOption">Number</Option>
|
||||
<Select
|
||||
value={param.type}
|
||||
onChange={(type) => setParam({ ...param, type })}
|
||||
data-test="ParameterTypeSelect"
|
||||
>
|
||||
<Option value="text" data-test="TextParameterTypeOption">
|
||||
Text
|
||||
</Option>
|
||||
<Option value="number" data-test="NumberParameterTypeOption">
|
||||
Number
|
||||
</Option>
|
||||
<Option value="enum">Dropdown List</Option>
|
||||
<Option value="query">Query Based Dropdown List</Option>
|
||||
<Option disabled key="dv1">
|
||||
<Divider className="select-option-divider" />
|
||||
</Option>
|
||||
<Option value="date" data-test="DateParameterTypeOption">Date</Option>
|
||||
<Option value="datetime-local" data-test="DateTimeParameterTypeOption">Date and Time</Option>
|
||||
<Option value="datetime-with-seconds">Date and Time (with seconds)</Option>
|
||||
<Option value="date" data-test="DateParameterTypeOption">
|
||||
Date
|
||||
</Option>
|
||||
<Option
|
||||
value="datetime-local"
|
||||
data-test="DateTimeParameterTypeOption"
|
||||
>
|
||||
Date and Time
|
||||
</Option>
|
||||
<Option value="datetime-with-seconds">
|
||||
Date and Time (with seconds)
|
||||
</Option>
|
||||
<Option disabled key="dv2">
|
||||
<Divider className="select-option-divider" />
|
||||
</Option>
|
||||
<Option value="date-range" data-test="DateRangeParameterTypeOption">Date Range</Option>
|
||||
<Option value="date-range" data-test="DateRangeParameterTypeOption">
|
||||
Date Range
|
||||
</Option>
|
||||
<Option value="datetime-range">Date and Time Range</Option>
|
||||
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
|
||||
<Option value="datetime-range-with-seconds">
|
||||
Date and Time Range (with seconds)
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{param.type === 'enum' && (
|
||||
<Form.Item label="Values" help="Dropdown list values (newline delimeted)" {...formItemProps}>
|
||||
{param.type === "enum" && (
|
||||
<Form.Item
|
||||
label="Values"
|
||||
help="Dropdown list values (newline delimited)"
|
||||
{...formItemProps}
|
||||
>
|
||||
<Input.TextArea
|
||||
data-test="EnumTextArea"
|
||||
rows={3}
|
||||
value={param.enumOptions}
|
||||
onChange={e => setParam({ ...param, enumOptions: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setParam({ ...param, enumOptions: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{param.type === 'query' && (
|
||||
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
|
||||
{param.type === "query" && (
|
||||
<Form.Item
|
||||
label="Query"
|
||||
help="Select query to load dropdown values from"
|
||||
{...formItemProps}
|
||||
>
|
||||
<QuerySelector
|
||||
selectedQuery={initialQuery}
|
||||
onChange={q => setParam({ ...param, queryId: q && q.id })}
|
||||
onChange={(q) => setParam({ ...param, queryId: q && q.id })}
|
||||
type="select"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{(param.type === 'enum' || param.type === 'query') && (
|
||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||
{(param.type === "enum" || param.type === "query") && (
|
||||
<Form.Item
|
||||
className="m-b-0"
|
||||
label=" "
|
||||
colon={false}
|
||||
{...formItemProps}
|
||||
>
|
||||
<Checkbox
|
||||
defaultChecked={!!param.multiValuesOptions}
|
||||
onChange={e => setParam({ ...param,
|
||||
multiValuesOptions: e.target.checked ? {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
separator: ',',
|
||||
} : null })}
|
||||
onChange={(e) =>
|
||||
setParam({
|
||||
...param,
|
||||
multiValuesOptions: e.target.checked
|
||||
? {
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
separator: ",",
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
data-test="AllowMultipleValuesCheckbox"
|
||||
>
|
||||
Allow multiple values
|
||||
Allow multiple values
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{(param.type === 'enum' || param.type === 'query') && param.multiValuesOptions && (
|
||||
<Form.Item
|
||||
label="Quotation"
|
||||
help={(
|
||||
<React.Fragment>
|
||||
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{...formItemProps}
|
||||
>
|
||||
<Select
|
||||
value={param.multiValuesOptions.prefix}
|
||||
onChange={quoteOption => setParam({ ...param,
|
||||
multiValuesOptions: {
|
||||
...param.multiValuesOptions,
|
||||
prefix: quoteOption,
|
||||
suffix: quoteOption,
|
||||
} })}
|
||||
data-test="QuotationSelect"
|
||||
{(param.type === "enum" || param.type === "query") &&
|
||||
param.multiValuesOptions && (
|
||||
<Form.Item
|
||||
label="Quotation"
|
||||
help={
|
||||
<React.Fragment>
|
||||
Placed in query as:{" "}
|
||||
<code>{joinExampleList(param.multiValuesOptions)}</code>
|
||||
</React.Fragment>
|
||||
}
|
||||
{...formItemProps}
|
||||
>
|
||||
<Option value="">None (default)</Option>
|
||||
<Option value="'">Single Quotation Mark</Option>
|
||||
<Option value={'"'} data-test="DoubleQuotationMarkOption">Double Quotation Mark</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Select
|
||||
value={param.multiValuesOptions.prefix}
|
||||
onChange={(quoteOption) =>
|
||||
setParam({
|
||||
...param,
|
||||
multiValuesOptions: {
|
||||
...param.multiValuesOptions,
|
||||
prefix: quoteOption,
|
||||
suffix: quoteOption,
|
||||
},
|
||||
})
|
||||
}
|
||||
data-test="QuotationSelect"
|
||||
>
|
||||
<Option value="">None (default)</Option>
|
||||
<Option value="'">Single Quotation Mark</Option>
|
||||
<Option value={'"'} data-test="DoubleQuotationMarkOption">
|
||||
Double Quotation Mark
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function QueryResultsLink(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<a target="_self" disabled={props.disabled} href={href}>
|
||||
<a target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { currentUser, clientConfig } from '@/services/auth';
|
||||
import cx from 'classnames';
|
||||
import { clientConfig, currentUser } from '@/services/auth';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import Alert from 'antd/lib/alert';
|
||||
import HelpTrigger from '@/components/HelpTrigger';
|
||||
|
||||
export function EmailSettingsWarning({ featureName }) {
|
||||
return (clientConfig.mailSettingsMissing && currentUser.isAdmin) ? (
|
||||
<p className="alert alert-danger">
|
||||
{`It looks like your mail server isn't configured. Make sure to configure it for the ${featureName} to work.`}
|
||||
</p>
|
||||
) : null;
|
||||
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }) {
|
||||
if (!clientConfig.mailSettingsMissing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adminOnly && !currentUser.isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = (
|
||||
<span>
|
||||
Your mail server isn't configured correctly, and is needed for {featureName} to work.{' '}
|
||||
<HelpTrigger type="MAIL_CONFIG" className="f-inherit" />
|
||||
</span>
|
||||
);
|
||||
|
||||
if (mode === 'icon') {
|
||||
return (
|
||||
<Tooltip title={message}>
|
||||
<i className={cx('fa fa-exclamation-triangle', className)} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert message={message} type="error" className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
EmailSettingsWarning.propTypes = {
|
||||
featureName: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
mode: PropTypes.oneOf(['alert', 'icon']),
|
||||
adminOnly: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('emailSettingsWarning', react2angular(EmailSettingsWarning));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
EmailSettingsWarning.defaultProps = {
|
||||
className: null,
|
||||
mode: 'alert',
|
||||
adminOnly: false,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { isArray, indexOf, get, map, includes, every, some, toNumber, toLower } from 'lodash';
|
||||
import { isArray, indexOf, get, map, includes, every, some, toNumber } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Select from 'antd/lib/select';
|
||||
import { formatDateTime, formatDate } from '@/filters/datetime';
|
||||
import { formatColumnValue } from '@/filters';
|
||||
|
||||
const ALL_VALUES = '###Redash::Filters::SelectAll###';
|
||||
const NONE_VALUES = '###Redash::Filters::Clear###';
|
||||
@@ -71,21 +71,6 @@ export function filterData(rows, filters = []) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatValue(value, columnType) {
|
||||
if (moment.isMoment(value)) {
|
||||
if (columnType === 'date') {
|
||||
return formatDate(value);
|
||||
}
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function Filters({ filters, onChange }) {
|
||||
if (filters.length === 0) {
|
||||
return null;
|
||||
@@ -99,7 +84,7 @@ export function Filters({ filters, onChange }) {
|
||||
<div className="row">
|
||||
{map(filters, (filter) => {
|
||||
const options = map(filter.values, (value, index) => (
|
||||
<Select.Option key={index}>{formatValue(value, get(filter, 'column.type'))}</Select.Option>
|
||||
<Select.Option key={index}>{formatColumnValue(value, get(filter, 'column.type'))}</Select.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
@@ -115,10 +100,10 @@ export function Filters({ filters, onChange }) {
|
||||
mode={filter.multiple ? 'multiple' : 'default'}
|
||||
value={isArray(filter.current) ?
|
||||
map(filter.current,
|
||||
value => ({ key: `${indexOf(filter.values, value)}`, label: formatValue(value) })) :
|
||||
({ key: `${indexOf(filter.values, filter.current)}`, label: formatValue(filter.current) })}
|
||||
value => ({ key: `${indexOf(filter.values, value)}`, label: formatColumnValue(value) })) :
|
||||
({ key: `${indexOf(filter.values, filter.current)}`, label: formatColumnValue(filter.current) })}
|
||||
allowClear={filter.multiple}
|
||||
filterOption={(searchText, option) => includes(toLower(option.props.children), toLower(searchText))}
|
||||
optionFilterProp="children"
|
||||
showSearch
|
||||
onChange={values => onChange(filter, values)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { react2angular } from 'react2angular';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
@@ -32,6 +31,10 @@ export const TYPES = {
|
||||
'/user-guide/users/authentication-options',
|
||||
'Guide: Authentication Options',
|
||||
],
|
||||
USAGE_DATA_SHARING: [
|
||||
'/open-source/admin-guide/usage-data',
|
||||
'Help: Anonymous Usage Data Sharing',
|
||||
],
|
||||
DS_ATHENA: [
|
||||
'/data-sources/amazon-athena-setup',
|
||||
'Guide: Help Setting up Amazon Athena',
|
||||
@@ -64,9 +67,25 @@ export const TYPES = {
|
||||
'/user-guide/querying/query-results-data-source',
|
||||
'Guide: Help Setting up Query Results',
|
||||
],
|
||||
ALERT_SETUP: [
|
||||
'/user-guide/alerts/setting-up-an-alert',
|
||||
'Guide: Setting Up a New Alert',
|
||||
],
|
||||
MAIL_CONFIG: [
|
||||
'/open-source/setup/#Mail-Configuration',
|
||||
'Guide: Mail Configuration',
|
||||
],
|
||||
ALERT_NOTIF_TEMPLATE_GUIDE: [
|
||||
'/user-guide/alerts/custom-alert-notifications',
|
||||
'Guide: Custom Alerts Notifications',
|
||||
],
|
||||
FAVORITES: [
|
||||
'/user-guide/querying/favorites-tagging/#Favorites',
|
||||
'Guide: Favorites',
|
||||
],
|
||||
};
|
||||
|
||||
export class HelpTrigger extends React.Component {
|
||||
export default class HelpTrigger extends React.Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.oneOf(Object.keys(TYPES)).isRequired,
|
||||
className: PropTypes.string,
|
||||
@@ -220,9 +239,3 @@ export class HelpTrigger extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('helpTrigger', react2angular(HelpTrigger));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Button from 'antd/lib/button';
|
||||
import Badge from 'antd/lib/badge';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import { KeyboardShortcuts } from '@/services/keyboard-shortcuts';
|
||||
|
||||
function ParameterApplyButton({ paramCount, onClick, isApplying }) {
|
||||
// show spinner when applying (also when count is empty so the fade out is consistent)
|
||||
const icon = isApplying || !paramCount ? 'spinner fa-pulse' : 'check';
|
||||
function ParameterApplyButton({ paramCount, onClick }) {
|
||||
// show spinner when count is empty so the fade out is consistent
|
||||
const icon = !paramCount ? 'spinner fa-pulse' : 'check';
|
||||
|
||||
return (
|
||||
<div className="parameter-apply-button" data-show={!!paramCount} data-test="ParameterApplyButton">
|
||||
@@ -28,11 +27,6 @@ function ParameterApplyButton({ paramCount, onClick, isApplying }) {
|
||||
ParameterApplyButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
paramCount: PropTypes.number.isRequired,
|
||||
isApplying: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('parameterApplyButton', react2angular(ParameterApplyButton));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default ParameterApplyButton;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
|
||||
import { isString, extend, each, map, includes, findIndex, find, fromPairs, clone, isEmpty } from 'lodash';
|
||||
import { isString, extend, each, has, map, includes, findIndex, find,
|
||||
fromPairs, clone, isEmpty } from 'lodash';
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
@@ -14,10 +15,10 @@ import Input from 'antd/lib/input';
|
||||
import Radio from 'antd/lib/radio';
|
||||
import Form from 'antd/lib/form';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import { ParameterValueInput } from '@/components/ParameterValueInput';
|
||||
import ParameterValueInput from '@/components/ParameterValueInput';
|
||||
import { ParameterMappingType } from '@/services/widget';
|
||||
import { Parameter } from '@/services/query';
|
||||
import { HelpTrigger } from '@/components/HelpTrigger';
|
||||
import { Parameter } from '@/services/parameters';
|
||||
import HelpTrigger from '@/components/HelpTrigger';
|
||||
|
||||
import './ParameterMappingInput.less';
|
||||
|
||||
@@ -158,6 +159,13 @@ export class ParameterMappingInput extends React.Component {
|
||||
newMapping.param = newMapping.param.clone();
|
||||
newMapping.param.setValue(newMapping.value);
|
||||
}
|
||||
if (has(update, 'type')) {
|
||||
if (update.type === MappingType.StaticValue) {
|
||||
newMapping.value = newMapping.param.value;
|
||||
} else {
|
||||
newMapping.value = null;
|
||||
}
|
||||
}
|
||||
onChange(newMapping);
|
||||
};
|
||||
|
||||
@@ -168,7 +176,7 @@ export class ParameterMappingInput extends React.Component {
|
||||
value={this.props.mapping.type}
|
||||
onChange={e => this.updateSourceType(e.target.value)}
|
||||
>
|
||||
<Radio className="radio" value={MappingType.DashboardAddNew}>
|
||||
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
|
||||
New dashboard parameter
|
||||
</Radio>
|
||||
<Radio
|
||||
@@ -183,10 +191,10 @@ export class ParameterMappingInput extends React.Component {
|
||||
</Tooltip>
|
||||
) : null }
|
||||
</Radio>
|
||||
<Radio className="radio" value={MappingType.WidgetLevel}>
|
||||
<Radio className="radio" value={MappingType.WidgetLevel} data-test="WidgetParameterOption">
|
||||
Widget parameter
|
||||
</Radio>
|
||||
<Radio className="radio" value={MappingType.StaticValue}>
|
||||
<Radio className="radio" value={MappingType.StaticValue} data-test="StaticValueOption">
|
||||
Static value
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
@@ -335,7 +343,7 @@ class MappingEditor extends React.Component {
|
||||
const { mapping, inputError } = this.state;
|
||||
|
||||
return (
|
||||
<div className="parameter-mapping-editor">
|
||||
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
||||
<header>
|
||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||
</header>
|
||||
@@ -354,15 +362,16 @@ class MappingEditor extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { visible, mapping } = this.state;
|
||||
return (
|
||||
<Popover
|
||||
placement="left"
|
||||
trigger="click"
|
||||
content={this.renderContent()}
|
||||
visible={this.state.visible}
|
||||
visible={visible}
|
||||
onVisibleChange={this.onVisibleChange}
|
||||
>
|
||||
<Button size="small" type="dashed">
|
||||
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
|
||||
<Icon type="edit" />
|
||||
</Button>
|
||||
</Popover>
|
||||
@@ -536,11 +545,11 @@ export class ParameterMappingListInput extends React.Component {
|
||||
param = param.clone().setValue(mapping.value);
|
||||
}
|
||||
|
||||
let value = Parameter.getValue(param);
|
||||
let value = Parameter.getExecutionValue(param);
|
||||
|
||||
// in case of dynamic value display the name instead of value
|
||||
if (param.hasDynamicValue) {
|
||||
value = param.dynamicValue.name;
|
||||
value = param.normalizedValue.name;
|
||||
}
|
||||
|
||||
return this.getStringValue(value);
|
||||
|
||||
@@ -1,51 +1,50 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Select from 'antd/lib/select';
|
||||
import Input from 'antd/lib/input';
|
||||
import InputNumber from 'antd/lib/input-number';
|
||||
import DateParameter from '@/components/dynamic-parameters/DateParameter';
|
||||
import DateRangeParameter from '@/components/dynamic-parameters/DateRangeParameter';
|
||||
import { toString } from 'lodash';
|
||||
import { QueryBasedParameterInput } from './QueryBasedParameterInput';
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Select from "antd/lib/select";
|
||||
import Input from "antd/lib/input";
|
||||
import InputNumber from "antd/lib/input-number";
|
||||
import DateParameter from "@/components/dynamic-parameters/DateParameter";
|
||||
import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
|
||||
import { isEqual, trim } from "lodash";
|
||||
import { QueryBasedParameterInput } from "./QueryBasedParameterInput";
|
||||
|
||||
import './ParameterValueInput.less';
|
||||
import "./ParameterValueInput.less";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const multipleValuesProps = {
|
||||
maxTagCount: 3,
|
||||
maxTagTextLength: 10,
|
||||
maxTagPlaceholder: num => `+${num.length} more`,
|
||||
maxTagPlaceholder: (num) => `+${num.length} more`,
|
||||
};
|
||||
|
||||
export class ParameterValueInput extends React.Component {
|
||||
class ParameterValueInput extends React.Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.string,
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
enumOptions: PropTypes.string,
|
||||
queryId: PropTypes.number,
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
allowMultipleValues: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
type: 'text',
|
||||
type: "text",
|
||||
value: null,
|
||||
enumOptions: '',
|
||||
enumOptions: "",
|
||||
queryId: null,
|
||||
parameter: null,
|
||||
allowMultipleValues: false,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
className: "",
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: props.parameter.hasPendingValue ? props.parameter.pendingValue : props.value,
|
||||
value: props.parameter.hasPendingValue
|
||||
? props.parameter.pendingValue
|
||||
: props.value,
|
||||
isDirty: props.parameter.hasPendingValue,
|
||||
};
|
||||
}
|
||||
@@ -59,13 +58,13 @@ export class ParameterValueInput extends React.Component {
|
||||
isDirty: parameter.hasPendingValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onSelect = (value) => {
|
||||
const isDirty = toString(value) !== toString(this.props.value);
|
||||
const isDirty = !isEqual(trim(value), trim(this.props.value));
|
||||
this.setState({ value, isDirty });
|
||||
this.props.onSelect(value, isDirty);
|
||||
}
|
||||
};
|
||||
|
||||
renderDateParameter() {
|
||||
const { type, parameter } = this.props;
|
||||
@@ -96,37 +95,43 @@ export class ParameterValueInput extends React.Component {
|
||||
}
|
||||
|
||||
renderEnumInput() {
|
||||
const { enumOptions, allowMultipleValues } = this.props;
|
||||
const { enumOptions, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
const enumOptionsArray = enumOptions.split('\n').filter(v => v !== '');
|
||||
const enumOptionsArray = enumOptions.split("\n").filter((v) => v !== "");
|
||||
// Antd Select doesn't handle null in multiple mode
|
||||
const normalize = (val) =>
|
||||
parameter.multiValuesOptions && val === null ? [] : val;
|
||||
return (
|
||||
<Select
|
||||
className={this.props.className}
|
||||
mode={allowMultipleValues ? 'multiple' : 'default'}
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
optionFilterProp="children"
|
||||
disabled={enumOptionsArray.length === 0}
|
||||
value={value}
|
||||
value={normalize(value)}
|
||||
onChange={this.onSelect}
|
||||
dropdownMatchSelectWidth={false}
|
||||
dropdownClassName="ant-dropdown-in-bootstrap-modal"
|
||||
showSearch
|
||||
showArrow
|
||||
style={{ minWidth: 60 }}
|
||||
notFoundContent={null}
|
||||
{...multipleValuesProps}
|
||||
>
|
||||
{enumOptionsArray.map(option => (<Option key={option} value={option}>{ option }</Option>))}
|
||||
{enumOptionsArray.map((option) => (
|
||||
<Option key={option} value={option}>
|
||||
{option}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
renderQueryBasedInput() {
|
||||
const { queryId, parameter, allowMultipleValues } = this.props;
|
||||
const { queryId, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<QueryBasedParameterInput
|
||||
className={this.props.className}
|
||||
mode={allowMultipleValues ? 'multiple' : 'default'}
|
||||
mode={parameter.multiValuesOptions ? "multiple" : "default"}
|
||||
optionFilterProp="children"
|
||||
parameter={parameter}
|
||||
value={value}
|
||||
@@ -142,13 +147,11 @@ export class ParameterValueInput extends React.Component {
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
const normalize = val => !isNaN(val) && val || 0;
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
className={className}
|
||||
value={normalize(value)}
|
||||
onChange={val => this.onSelect(normalize(val))}
|
||||
value={value}
|
||||
onChange={(val) => this.onSelect(val)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -162,7 +165,7 @@ export class ParameterValueInput extends React.Component {
|
||||
className={className}
|
||||
value={value}
|
||||
data-test="TextParamInput"
|
||||
onChange={e => this.onSelect(e.target.value)}
|
||||
onChange={(e) => this.onSelect(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -170,16 +173,22 @@ export class ParameterValueInput extends React.Component {
|
||||
renderInput() {
|
||||
const { type } = this.props;
|
||||
switch (type) {
|
||||
case 'datetime-with-seconds':
|
||||
case 'datetime-local':
|
||||
case 'date': return this.renderDateParameter();
|
||||
case 'datetime-range-with-seconds':
|
||||
case 'datetime-range':
|
||||
case 'date-range': return this.renderDateRangeParameter();
|
||||
case 'enum': return this.renderEnumInput();
|
||||
case 'query': return this.renderQueryBasedInput();
|
||||
case 'number': return this.renderNumberInput();
|
||||
default: return this.renderTextInput();
|
||||
case "datetime-with-seconds":
|
||||
case "datetime-local":
|
||||
case "date":
|
||||
return this.renderDateParameter();
|
||||
case "datetime-range-with-seconds":
|
||||
case "datetime-range":
|
||||
case "date-range":
|
||||
return this.renderDateRangeParameter();
|
||||
case "enum":
|
||||
return this.renderEnumInput();
|
||||
case "query":
|
||||
return this.renderQueryBasedInput();
|
||||
case "number":
|
||||
return this.renderNumberInput();
|
||||
default:
|
||||
return this.renderTextInput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,41 +196,15 @@ export class ParameterValueInput extends React.Component {
|
||||
const { isDirty } = this.state;
|
||||
|
||||
return (
|
||||
<div className="parameter-input" data-dirty={isDirty || null}>
|
||||
<div
|
||||
className="parameter-input"
|
||||
data-dirty={isDirty || null}
|
||||
data-test="ParameterValueInput"
|
||||
>
|
||||
{this.renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('parameterValueInput', {
|
||||
template: `
|
||||
<parameter-value-input-impl
|
||||
type="$ctrl.param.type"
|
||||
value="$ctrl.param.normalizedValue"
|
||||
parameter="$ctrl.param"
|
||||
enum-options="$ctrl.param.enumOptions"
|
||||
query-id="$ctrl.param.queryId"
|
||||
allow-multiple-values="!!$ctrl.param.multiValuesOptions"
|
||||
on-select="$ctrl.setValue"
|
||||
></parameter-value-input-impl>
|
||||
`,
|
||||
bindings: {
|
||||
param: '<',
|
||||
},
|
||||
controller($scope) {
|
||||
this.setValue = (value, isDirty) => {
|
||||
if (isDirty) {
|
||||
this.param.setPendingValue(value);
|
||||
} else {
|
||||
this.param.clearPendingValue();
|
||||
}
|
||||
$scope.$apply();
|
||||
};
|
||||
},
|
||||
});
|
||||
ngModule.component('parameterValueInputImpl', react2angular(ParameterValueInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default ParameterValueInput;
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
.parameter-input {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.@{ant-prefix}-input[type="text"] {
|
||||
width: 195px;
|
||||
.@{ant-prefix}-input,
|
||||
.@{ant-prefix}-input-number {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
.@{ant-prefix}-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&[data-dirty] {
|
||||
@@ -18,65 +24,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-container {
|
||||
position: relative;
|
||||
|
||||
.parameter-apply-button {
|
||||
display: none; // default for mobile
|
||||
|
||||
// "floating" on desktop
|
||||
@media (min-width: 768px) {
|
||||
position: absolute;
|
||||
bottom: -42px;
|
||||
left: -15px;
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
transition: opacity 150ms ease-out;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
background-color: #ffffff;
|
||||
padding: 4px;
|
||||
padding-left: 16px;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
pointer-events: none; // so tooltip doesn't remain after button hides
|
||||
}
|
||||
|
||||
&[data-show="true"] {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0 8px 0 6px;
|
||||
color: #2096f3;
|
||||
border-color: #50acf6;
|
||||
|
||||
// smaller on desktop
|
||||
@media (min-width: 768px) {
|
||||
font-size: 12px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
background-color: #eef7fe;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-badge-count {
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
background: #f77b74;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0px 0px 0 1px white, -1px 1px 0 1px #5d6f7d85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
261
client/app/components/Parameters.jsx
Normal file
261
client/app/components/Parameters.jsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { size, filter, forEach, extend, get, includes } from "lodash";
|
||||
import { react2angular } from "react2angular";
|
||||
import {
|
||||
SortableContainer,
|
||||
SortableElement,
|
||||
DragHandle,
|
||||
} from "@/components/sortable";
|
||||
import { $location } from "@/services/ng";
|
||||
import { Parameter } from "@/services/parameters";
|
||||
import ParameterApplyButton from "@/components/ParameterApplyButton";
|
||||
import ParameterValueInput from "@/components/ParameterValueInput";
|
||||
import Form from "antd/lib/form";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
|
||||
import { toHuman } from "@/filters";
|
||||
|
||||
import "./Parameters.less";
|
||||
|
||||
function updateUrl(parameters) {
|
||||
const params = extend({}, $location.search());
|
||||
parameters.forEach((param) => {
|
||||
extend(params, param.toUrlParams());
|
||||
});
|
||||
Object.keys(params).forEach(
|
||||
(key) => params[key] == null && delete params[key]
|
||||
);
|
||||
$location.search(params);
|
||||
}
|
||||
|
||||
export class Parameters extends React.Component {
|
||||
static propTypes = {
|
||||
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
||||
editable: PropTypes.bool,
|
||||
disableUrlUpdate: PropTypes.bool,
|
||||
onValuesChange: PropTypes.func,
|
||||
onPendingValuesChange: PropTypes.func,
|
||||
onParametersEdit: PropTypes.func,
|
||||
queryResultErrorData: PropTypes.shape({
|
||||
parameters: PropTypes.objectOf(PropTypes.string),
|
||||
}),
|
||||
unsavedParameters: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
parameters: [],
|
||||
editable: false,
|
||||
disableUrlUpdate: false,
|
||||
onValuesChange: () => {},
|
||||
onPendingValuesChange: () => {},
|
||||
onParametersEdit: () => {},
|
||||
queryResultErrorData: {},
|
||||
unsavedParameters: null,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { parameters } = props;
|
||||
this.state = {
|
||||
parameters,
|
||||
touched: {},
|
||||
};
|
||||
|
||||
if (!props.disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const { parameters, disableUrlUpdate, queryResultErrorData } = this.props;
|
||||
if (prevProps.parameters !== parameters) {
|
||||
this.setState({ parameters });
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
// reset touched flags on new error data
|
||||
if (prevProps.queryResultErrorData !== queryResultErrorData) {
|
||||
this.setState({ touched: {} });
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
// Cmd/Ctrl/Alt + Enter
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
||||
e.stopPropagation();
|
||||
this.applyChanges();
|
||||
}
|
||||
};
|
||||
|
||||
setPendingValue = (param, value, isDirty) => {
|
||||
const { onPendingValuesChange } = this.props;
|
||||
this.setState(({ parameters, touched }) => {
|
||||
if (isDirty) {
|
||||
param.setPendingValue(value);
|
||||
touched = { ...touched, [param.name]: true };
|
||||
} else {
|
||||
param.clearPendingValue();
|
||||
}
|
||||
onPendingValuesChange();
|
||||
return { parameters, touched };
|
||||
});
|
||||
};
|
||||
|
||||
moveParameter = ({ oldIndex, newIndex }) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
if (oldIndex !== newIndex) {
|
||||
this.setState(({ parameters }) => {
|
||||
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
||||
onParametersEdit();
|
||||
return { parameters };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
applyChanges = () => {
|
||||
const { onValuesChange, disableUrlUpdate } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
const parametersWithPendingValues = parameters.filter(
|
||||
(p) => p.hasPendingValue
|
||||
);
|
||||
forEach(parameters, (p) => p.applyPendingValue());
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
onValuesChange(parametersWithPendingValues);
|
||||
return { parameters };
|
||||
});
|
||||
};
|
||||
|
||||
showParameterSettings = (parameter, index) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
EditParameterSettingsDialog.showModal({ parameter }).result.then(
|
||||
(updated) => {
|
||||
this.setState(({ parameters, touched }) => {
|
||||
touched = { ...touched, [parameter.name]: true };
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = Parameter.create(
|
||||
updatedParameter,
|
||||
updatedParameter.parentQueryId
|
||||
);
|
||||
onParametersEdit();
|
||||
return { parameters, touched };
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
getParameterFeedback = (param) => {
|
||||
// error msg
|
||||
const { queryResultErrorData } = this.props;
|
||||
const error = get(queryResultErrorData, ["parameters", param.name], false);
|
||||
if (error) {
|
||||
const feedback = <Tooltip title={error}>{error}</Tooltip>;
|
||||
return [feedback, "error"];
|
||||
}
|
||||
|
||||
// unsaved
|
||||
const { unsavedParameters } = this.props;
|
||||
if (includes(unsavedParameters, param.name)) {
|
||||
const feedback = (
|
||||
<>
|
||||
Unsaved{" "}
|
||||
<Tooltip title='Click the "Save" button to preserve this parameter.'>
|
||||
<i className="fa fa-question-circle" />
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
return [feedback, "warning"];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
renderParameter(param, index) {
|
||||
const { editable } = this.props;
|
||||
const touched = this.state.touched[param.name];
|
||||
const [feedback, status] = this.getParameterFeedback(param);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={param.name}
|
||||
className="di-block"
|
||||
data-test={`ParameterName-${param.name}`}
|
||||
>
|
||||
<div className="parameter-heading">
|
||||
<label>{param.title || toHuman(param.name)}</label>
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-default btn-xs m-l-5"
|
||||
onClick={() => this.showParameterSettings(param, index)}
|
||||
data-test={`ParameterSettings-${param.name}`}
|
||||
type="button"
|
||||
>
|
||||
<i className="fa fa-cog" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Form.Item
|
||||
validateStatus={touched ? "" : status}
|
||||
help={feedback || null}
|
||||
>
|
||||
<ParameterValueInput
|
||||
type={param.type}
|
||||
value={param.normalizedValue}
|
||||
parameter={param}
|
||||
enumOptions={param.enumOptions}
|
||||
queryId={param.queryId}
|
||||
onSelect={(value, isDirty) =>
|
||||
this.setPendingValue(param, value, isDirty)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { parameters } = this.state;
|
||||
const { editable } = this.props;
|
||||
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
|
||||
return (
|
||||
<SortableContainer
|
||||
disabled={!editable}
|
||||
axis="xy"
|
||||
useDragHandle
|
||||
lockToContainerEdges
|
||||
helperClass="parameter-dragged"
|
||||
updateBeforeSortStart={this.onBeforeSortStart}
|
||||
onSortEnd={this.moveParameter}
|
||||
containerProps={{
|
||||
className: "parameter-container",
|
||||
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
|
||||
}}
|
||||
>
|
||||
{parameters.map((param, index) => (
|
||||
<SortableElement key={param.name} index={index}>
|
||||
<div className="parameter-block" data-editable={editable || null}>
|
||||
{editable && (
|
||||
<DragHandle data-test={`DragHandle-${param.name}`} />
|
||||
)}
|
||||
{this.renderParameter(param, index)}
|
||||
</div>
|
||||
</SortableElement>
|
||||
))}
|
||||
<ParameterApplyButton
|
||||
onClick={this.applyChanges}
|
||||
paramCount={dirtyParamCount}
|
||||
/>
|
||||
</SortableContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component("parameters", react2angular(Parameters));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
129
client/app/components/Parameters.less
Normal file
129
client/app/components/Parameters.less
Normal file
@@ -0,0 +1,129 @@
|
||||
@import '../assets/less/ant';
|
||||
|
||||
.parameter-block {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
padding: 0 12px 17px 0;
|
||||
vertical-align: top;
|
||||
z-index: 1;
|
||||
|
||||
.drag-handle {
|
||||
padding: 0 5px;
|
||||
margin-left: -5px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.parameter-container.sortable-container & {
|
||||
margin: 4px 0 0 4px;
|
||||
padding: 3px 6px 19px;
|
||||
}
|
||||
|
||||
&.parameter-dragged {
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-form-explain {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -20px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-form-item-control {
|
||||
line-height: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 4px;
|
||||
|
||||
label {
|
||||
margin-bottom: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 100%;
|
||||
max-width: 195px;
|
||||
white-space: nowrap;
|
||||
|
||||
.parameter-block[data-editable] & {
|
||||
min-width: calc(100% - 27px); // make room for settings button
|
||||
max-width: 195px - 27px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-container {
|
||||
position: relative;
|
||||
|
||||
&.sortable-container {
|
||||
padding: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.parameter-apply-button {
|
||||
display: none; // default for mobile
|
||||
|
||||
// "floating" on desktop
|
||||
@media (min-width: 768px) {
|
||||
position: absolute;
|
||||
bottom: -36px;
|
||||
left: -15px;
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
transition: opacity 150ms ease-out;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
background-color: #ffffff;
|
||||
padding: 4px;
|
||||
padding-left: 16px;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
pointer-events: none; // so tooltip doesn't remain after button hides
|
||||
}
|
||||
|
||||
&[data-show="true"] {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0 8px 0 6px;
|
||||
color: #2096f3;
|
||||
border-color: #50acf6;
|
||||
|
||||
// smaller on desktop
|
||||
@media (min-width: 768px) {
|
||||
font-size: 12px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
background-color: #eef7fe;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-badge-count {
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
background: #f77b74;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 0 0 1px white, -1px 1px 0 1px #5d6f7d85;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { find, isFunction, isArray, isEqual, toString, map, intersection } from 'lodash';
|
||||
import { find, isArray, map, intersection, isEqual } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
@@ -29,6 +29,7 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
options: [],
|
||||
value: null,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
@@ -41,6 +42,24 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
if (this.props.queryId !== prevProps.queryId) {
|
||||
this._loadOptions(this.props.queryId);
|
||||
}
|
||||
if (this.props.value !== prevProps.value) {
|
||||
this.setValue(this.props.value);
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value) {
|
||||
const { options } = this.state;
|
||||
if (this.props.mode === 'multiple') {
|
||||
value = isArray(value) ? value : [value];
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(value, optionValues);
|
||||
this.setState({ value: validValues });
|
||||
return validValues;
|
||||
}
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
value = found ? value : options[0].value;
|
||||
this.setState({ value });
|
||||
return value;
|
||||
}
|
||||
|
||||
async _loadOptions(queryId) {
|
||||
@@ -50,20 +69,12 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
this.setState({ options, loading: false });
|
||||
|
||||
if (this.props.mode === 'multiple' && isArray(this.props.value)) {
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(this.props.value, optionValues);
|
||||
if (!isEqual(this.props.value, validValues)) {
|
||||
this.props.onSelect(validValues);
|
||||
this.setState({ options, loading: false }, () => {
|
||||
const updatedValue = this.setValue(this.props.value);
|
||||
if (!isEqual(updatedValue, this.props.value)) {
|
||||
this.props.onSelect(updatedValue);
|
||||
}
|
||||
} else {
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
if (!found && isFunction(this.props.onSelect)) {
|
||||
this.props.onSelect(options[0].value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,10 +89,9 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
disabled={loading || (options.length === 0)}
|
||||
loading={loading}
|
||||
mode={mode}
|
||||
value={isArray(value) ? value : toString(value)}
|
||||
value={this.state.value}
|
||||
onChange={onSelect}
|
||||
dropdownMatchSelectWidth={false}
|
||||
dropdownClassName="ant-dropdown-in-bootstrap-modal"
|
||||
optionFilterProp="children"
|
||||
showSearch
|
||||
showArrow
|
||||
|
||||
40
client/app/components/QueryLink.jsx
Normal file
40
client/app/components/QueryLink.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { VisualizationType } from '@/visualizations';
|
||||
import { VisualizationName } from '@/visualizations/VisualizationName';
|
||||
|
||||
function QueryLink({ query, visualization, readOnly }) {
|
||||
const getUrl = () => {
|
||||
let hash = null;
|
||||
if (visualization) {
|
||||
if (visualization.type === 'TABLE') {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
hash = 'table';
|
||||
} else {
|
||||
hash = visualization.id;
|
||||
}
|
||||
}
|
||||
|
||||
return query.getUrl(false, hash);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={readOnly ? null : getUrl()} className="query-link">
|
||||
<VisualizationName visualization={visualization} />{' '}
|
||||
<span>{query.name}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
QueryLink.propTypes = {
|
||||
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
visualization: VisualizationType,
|
||||
readOnly: PropTypes.bool,
|
||||
};
|
||||
|
||||
QueryLink.defaultProps = {
|
||||
visualization: null,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export default QueryLink;
|
||||
@@ -146,11 +146,13 @@ export function QuerySelector(props) {
|
||||
notFoundContent={null}
|
||||
filterOption={false}
|
||||
defaultActiveFirstOption={false}
|
||||
className={props.className}
|
||||
data-test="QuerySelector"
|
||||
>
|
||||
{searchResults && searchResults.map((q) => {
|
||||
const disabled = q.is_draft;
|
||||
return (
|
||||
<Option value={q.id} key={q.id} disabled={disabled}>
|
||||
<Option value={q.id} key={q.id} disabled={disabled} className="query-selector-result" data-test={`QueryId${q.id}`}>
|
||||
{q.name}{' '}
|
||||
<QueryTagsControl isDraft={q.is_draft} tags={q.tags} className={cx('inline-tags-control', { disabled })} />
|
||||
</Option>
|
||||
@@ -161,7 +163,7 @@ export function QuerySelector(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span data-test="QuerySelector">
|
||||
{selectedQuery ? (
|
||||
<Input value={selectedQuery.name} suffix={clearIcon} readOnly />
|
||||
) : (
|
||||
@@ -175,7 +177,7 @@ export function QuerySelector(props) {
|
||||
<div className="scrollbox" style={{ maxHeight: '50vh', marginTop: 15 }}>
|
||||
{searchResults && renderResults()}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,12 +185,14 @@ QuerySelector.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
selectedQuery: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
type: PropTypes.oneOf(['select', 'default']),
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
QuerySelector.defaultProps = {
|
||||
selectedQuery: null,
|
||||
type: 'default',
|
||||
className: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { filter, debounce, find } from 'lodash';
|
||||
import { filter, debounce, find, isEmpty, size } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Input from 'antd/lib/input';
|
||||
import List from 'antd/lib/list';
|
||||
import Button from 'antd/lib/button';
|
||||
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
|
||||
import { BigMessage } from '@/components/BigMessage';
|
||||
|
||||
@@ -29,6 +30,9 @@ class SelectItemsDialog extends React.Component {
|
||||
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
|
||||
renderStagedItem: PropTypes.func,
|
||||
save: PropTypes.func, // (selectedItems[]) => Promise<any>
|
||||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
extraFooterContent: PropTypes.node,
|
||||
showCount: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -37,8 +41,11 @@ class SelectItemsDialog extends React.Component {
|
||||
selectedItemsTitle: 'Selected items',
|
||||
itemKey: item => item.id,
|
||||
renderItem: () => '',
|
||||
renderStagedItem: null, // use `renderItem` by default
|
||||
renderStagedItem: null, // hidden by default
|
||||
save: items => items,
|
||||
width: '80%',
|
||||
extraFooterContent: null,
|
||||
showCount: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
@@ -108,7 +115,7 @@ class SelectItemsDialog extends React.Component {
|
||||
renderItem(item, isStagedList) {
|
||||
const { renderItem, renderStagedItem } = this.props;
|
||||
const isSelected = this.isSelected(item);
|
||||
const render = isStagedList ? (renderStagedItem || renderItem) : renderItem;
|
||||
const render = isStagedList ? renderStagedItem : renderItem;
|
||||
|
||||
const { content, className, isDisabled } = render(item, { isSelected });
|
||||
|
||||
@@ -123,23 +130,29 @@ class SelectItemsDialog extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dialog, dialogTitle, inputPlaceholder, selectedItemsTitle } = this.props;
|
||||
const { dialog, dialogTitle, inputPlaceholder } = this.props;
|
||||
const { selectedItemsTitle, renderStagedItem, width, showCount } = this.props;
|
||||
const { loading, saveInProgress, items, selected } = this.state;
|
||||
const hasResults = items.length > 0;
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
width="80%"
|
||||
className="select-items-dialog"
|
||||
width={width}
|
||||
title={dialogTitle}
|
||||
okText="Save"
|
||||
okButtonProps={{
|
||||
loading: saveInProgress,
|
||||
disabled: selected.length === 0,
|
||||
}}
|
||||
onOk={() => this.save()}
|
||||
footer={(
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="flex-fill m-r-5" style={{ textAlign: 'left', color: 'rgba(0, 0, 0, 0.5)' }}>{this.props.extraFooterContent}</span>
|
||||
<Button onClick={dialog.dismiss}>Cancel</Button>
|
||||
<Button onClick={() => this.save()} loading={saveInProgress} disabled={selected.length === 0} type="primary">
|
||||
Save
|
||||
{showCount && !isEmpty(selected) ? ` (${size(selected)})` : null}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center m-b-10">
|
||||
<div className="w-50 m-r-10">
|
||||
<div className="flex-fill">
|
||||
<Input.Search
|
||||
defaultValue={this.state.searchTerm}
|
||||
onChange={event => this.search(event.target.value)}
|
||||
@@ -147,13 +160,15 @@ class SelectItemsDialog extends React.Component {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="w-50 m-l-10">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20">
|
||||
<h5 className="m-0">{selectedItemsTitle}</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-stretch" style={{ minHeight: '30vh', maxHeight: '50vh' }}>
|
||||
<div className="w-50 m-r-10 scrollbox">
|
||||
<div className="flex-fill scrollbox">
|
||||
{loading && <LoadingState className="" />}
|
||||
{!loading && !hasResults && (
|
||||
<BigMessage icon="fa-search" message="No items match your search." className="" />
|
||||
@@ -166,15 +181,17 @@ class SelectItemsDialog extends React.Component {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-50 m-l-10 scrollbox">
|
||||
{(selected.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={selected}
|
||||
renderItem={item => this.renderItem(item, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{renderStagedItem && (
|
||||
<div className="w-50 m-l-20 scrollbox">
|
||||
{(selected.length > 0) && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={selected}
|
||||
renderItem={item => this.renderItem(item, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import moment from 'moment';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
@@ -17,7 +17,7 @@ export function Timer({ from }) {
|
||||
const diff = moment.now() - startTime;
|
||||
const format = diff > 1000 * 60 * 60 ? 'HH:mm:ss' : 'mm:ss'; // no HH under an hour
|
||||
|
||||
return moment.utc(diff).format(format);
|
||||
return (<span className="rd-timer">{moment.utc(diff).format(format)}</span>);
|
||||
}
|
||||
|
||||
Timer.propTypes = {
|
||||
|
||||
@@ -58,10 +58,6 @@ const queryColumns = commonColumns.concat([
|
||||
{ title: 'Scheduled', dataIndex: 'scheduled' },
|
||||
]);
|
||||
|
||||
const otherTasksColumns = commonColumns.concat([
|
||||
{ title: 'Task Name', dataIndex: 'task_name' },
|
||||
]);
|
||||
|
||||
const queuesColumns = map(
|
||||
['Name', 'Active', 'Reserved', 'Waiting'],
|
||||
c => ({ title: c, dataIndex: c.toLowerCase() }),
|
||||
@@ -97,16 +93,3 @@ export function QueriesTable({ loading, items }) {
|
||||
}
|
||||
|
||||
QueriesTable.propTypes = TablePropTypes;
|
||||
|
||||
export function OtherTasksTable({ loading, items }) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={otherTasksColumns}
|
||||
rowKey="task_id"
|
||||
dataSource={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
OtherTasksTable.propTypes = TablePropTypes;
|
||||
|
||||
@@ -18,6 +18,9 @@ export default function Layout({ activeTab, children }) {
|
||||
<Tabs.TabPane key="tasks" tab={<a href="admin/queries/tasks">Celery Status</a>}>
|
||||
{(activeTab === 'tasks') ? children : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="jobs" tab={<a href="admin/queries/jobs">RQ Status</a>}>
|
||||
{(activeTab === 'jobs') ? children : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="outdated_queries" tab={<a href="admin/queries/outdated">Outdated Queries</a>}>
|
||||
{(activeTab === 'outdated_queries') ? children : null}
|
||||
</Tabs.TabPane>
|
||||
|
||||
80
client/app/components/admin/RQStatus.jsx
Normal file
80
client/app/components/admin/RQStatus.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { map } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Badge from 'antd/lib/badge';
|
||||
import Table from 'antd/lib/table';
|
||||
import { Columns } from '@/components/items-list/components/ItemsTable';
|
||||
|
||||
// Tables
|
||||
|
||||
const otherJobsColumns = [
|
||||
{ title: 'Queue', dataIndex: 'queue' },
|
||||
{ title: 'Job Name', dataIndex: 'name' },
|
||||
Columns.timeAgo({ title: 'Start Time', dataIndex: 'started_at' }),
|
||||
Columns.timeAgo({ title: 'Enqueue Time', dataIndex: 'enqueued_at' }),
|
||||
];
|
||||
|
||||
const workersColumns = [Columns.custom(
|
||||
value => (
|
||||
<span><Badge status={{ busy: 'processing',
|
||||
idle: 'default',
|
||||
started: 'success',
|
||||
suspended: 'warning' }[value]}
|
||||
/> {value}
|
||||
</span>
|
||||
), { title: 'State', dataIndex: 'state' },
|
||||
)].concat(map(['Hostname', 'PID', 'Name', 'Queues', 'Current Job', 'Successful Jobs', 'Failed Jobs'],
|
||||
c => ({ title: c, dataIndex: c.toLowerCase().replace(/\s/g, '_') }))).concat([
|
||||
Columns.dateTime({ title: 'Birth Date', dataIndex: 'birth_date' }),
|
||||
Columns.duration({ title: 'Total Working Time', dataIndex: 'total_working_time' }),
|
||||
]);
|
||||
|
||||
const queuesColumns = map(
|
||||
['Name', 'Started', 'Queued'],
|
||||
c => ({ title: c, dataIndex: c.toLowerCase() }),
|
||||
);
|
||||
|
||||
const TablePropTypes = {
|
||||
loading: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
export function WorkersTable({ loading, items }) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={workersColumns}
|
||||
rowKey="name"
|
||||
dataSource={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
WorkersTable.propTypes = TablePropTypes;
|
||||
|
||||
export function QueuesTable({ loading, items }) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={queuesColumns}
|
||||
rowKey="name"
|
||||
dataSource={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
QueuesTable.propTypes = TablePropTypes;
|
||||
|
||||
export function OtherJobsTable({ loading, items }) {
|
||||
return (
|
||||
<Table
|
||||
loading={loading}
|
||||
columns={otherJobsColumns}
|
||||
rowKey="id"
|
||||
dataSource={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
OtherJobsTable.propTypes = TablePropTypes;
|
||||
@@ -1,27 +0,0 @@
|
||||
<div class="p-5">
|
||||
<h4>Notifications</h4>
|
||||
|
||||
<div>
|
||||
<ui-select ng-model="newSubscription.destination" ng-disabled="destinations.length == 0">
|
||||
<ui-select-match><span ng-bind-html="destinationsDisplay($select.selected)"></span></ui-select-match>
|
||||
<ui-select-choices repeat="d in destinations">
|
||||
<span ng-bind-html="destinationsDisplay(d)"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
<div class="m-t-5">
|
||||
<button class="btn btn-default" ng-click="saveSubscriber()" ng-disabled="destinations.length == 0" style="width:50%;">Add</button>
|
||||
<span class="pull-right m-t-5">
|
||||
<a href="destinations/new" ng-if="currentUser.isAdmin">Create New Destination</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div>
|
||||
<div class="list-group-item" ng-repeat="subscriber in subscribers">
|
||||
<span ng-bind-html="destinationsDisplay(subscriber)"></span>
|
||||
<button class="btn btn-xs btn-danger pull-right" ng-click="unsubscribe(subscriber)" ng-if="currentUser.isAdmin || currentUser.id == subscriber.user.id">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,116 +0,0 @@
|
||||
import { includes, without, compact } from 'lodash';
|
||||
import notification from '@/services/notification';
|
||||
import template from './alert-subscriptions.html';
|
||||
|
||||
function controller($scope, $q, $sce, currentUser, AlertSubscription, Destination) {
|
||||
'ngInject';
|
||||
|
||||
$scope.newSubscription = {};
|
||||
$scope.subscribers = [];
|
||||
$scope.destinations = [];
|
||||
$scope.currentUser = currentUser;
|
||||
|
||||
$q
|
||||
.all([
|
||||
Destination.query().$promise,
|
||||
AlertSubscription.query({ alertId: $scope.alertId }).$promise,
|
||||
])
|
||||
.then((responses) => {
|
||||
const destinations = responses[0];
|
||||
const subscribers = responses[1];
|
||||
|
||||
const mapF = s => s.destination && s.destination.id;
|
||||
const subscribedDestinations = compact(subscribers.map(mapF));
|
||||
|
||||
const subscribedUsers = compact(subscribers.map(s => !s.destination && s.user.id));
|
||||
|
||||
$scope.destinations = destinations.filter(d => !includes(subscribedDestinations, d.id));
|
||||
|
||||
if (!includes(subscribedUsers, currentUser.id)) {
|
||||
$scope.destinations.unshift({ user: { name: currentUser.name } });
|
||||
}
|
||||
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
$scope.subscribers = subscribers;
|
||||
});
|
||||
|
||||
$scope.destinationsDisplay = (d) => {
|
||||
if (!d) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let destination = d;
|
||||
if (d.destination) {
|
||||
destination = destination.destination;
|
||||
} else if (destination.user) {
|
||||
destination = {
|
||||
name: `${d.user.name} (Email)`,
|
||||
icon: 'fa-envelope',
|
||||
type: 'user',
|
||||
};
|
||||
}
|
||||
|
||||
return $sce.trustAsHtml(`<i class="fa ${destination.icon}"></i> ${destination.name}`);
|
||||
};
|
||||
|
||||
$scope.saveSubscriber = () => {
|
||||
const sub = new AlertSubscription({ alert_id: $scope.alertId });
|
||||
if ($scope.newSubscription.destination.id) {
|
||||
sub.destination_id = $scope.newSubscription.destination.id;
|
||||
}
|
||||
|
||||
sub.$save(
|
||||
() => {
|
||||
notification.success('Subscribed.');
|
||||
$scope.subscribers.push(sub);
|
||||
$scope.destinations = without($scope.destinations, $scope.newSubscription.destination);
|
||||
if ($scope.destinations.length > 0) {
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
} else {
|
||||
$scope.newSubscription.destination = undefined;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
notification.error('Failed saving subscription.');
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
$scope.unsubscribe = (subscriber) => {
|
||||
const destination = subscriber.destination;
|
||||
const user = subscriber.user;
|
||||
|
||||
subscriber.$delete(
|
||||
() => {
|
||||
notification.success('Unsubscribed');
|
||||
$scope.subscribers = without($scope.subscribers, subscriber);
|
||||
if (destination) {
|
||||
$scope.destinations.push(destination);
|
||||
} else if (user.id === currentUser.id) {
|
||||
$scope.destinations.push({ user: { name: currentUser.name } });
|
||||
}
|
||||
|
||||
if ($scope.destinations.length === 1) {
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
}
|
||||
},
|
||||
() => {
|
||||
notification.error('Failed unsubscribing.');
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('alertSubscriptions', () => ({
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
scope: {
|
||||
alertId: '=',
|
||||
},
|
||||
template,
|
||||
controller,
|
||||
}));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
257
client/app/components/app-header/AppHeader.jsx
Normal file
257
client/app/components/app-header/AppHeader.jsx
Normal file
@@ -0,0 +1,257 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
|
||||
import Dropdown from 'antd/lib/dropdown';
|
||||
import Button from 'antd/lib/button';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import Menu from 'antd/lib/menu';
|
||||
import Input from 'antd/lib/input';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
|
||||
import FavoritesDropdown from './components/FavoritesDropdown';
|
||||
import HelpTrigger from '@/components/HelpTrigger';
|
||||
import CreateDashboardDialog from '@/components/dashboards/CreateDashboardDialog';
|
||||
|
||||
import { currentUser, Auth, clientConfig } from '@/services/auth';
|
||||
import { $location, $route } from '@/services/ng';
|
||||
import { Dashboard } from '@/services/dashboard';
|
||||
import { Query } from '@/services/query';
|
||||
import frontendVersion from '@/version.json';
|
||||
import logoUrl from '@/assets/images/redash_icon_small.png';
|
||||
|
||||
import './AppHeader.less';
|
||||
|
||||
function onSearch(q) {
|
||||
$location.path('/queries').search({ q });
|
||||
$route.reload();
|
||||
}
|
||||
|
||||
function DesktopNavbar() {
|
||||
return (
|
||||
<div className="app-header" data-platform="desktop">
|
||||
<div>
|
||||
<Menu mode="horizontal" selectable={false}>
|
||||
{currentUser.hasPermission('list_dashboards') && (
|
||||
<Menu.Item key="dashboards" className="dropdown-menu-item">
|
||||
<Button href="dashboards">Dashboards</Button>
|
||||
<FavoritesDropdown fetch={Dashboard.favorites} urlTemplate="dashboard/${slug}" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('view_query') && (
|
||||
<Menu.Item key="queries" className="dropdown-menu-item">
|
||||
<Button href="queries">Queries</Button>
|
||||
<FavoritesDropdown fetch={Query.favorites} urlTemplate="queries/${id}" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('list_alerts') && (
|
||||
<Menu.Item key="alerts">
|
||||
<Button href="alerts">Alerts</Button>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
overlay={(
|
||||
<Menu>
|
||||
{currentUser.hasPermission('create_query') && (
|
||||
<Menu.Item key="new-query">
|
||||
<a href="queries/new">New Query</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('create_dashboard') && (
|
||||
<Menu.Item key="new-dashboard">
|
||||
<a onMouseUp={CreateDashboardDialog.showModal}>New Dashboard</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="new-alert">
|
||||
<a href="alerts/new">New Alert</a>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)}
|
||||
>
|
||||
<Button type="primary" data-test="CreateButton">
|
||||
Create <Icon type="down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="header-logo">
|
||||
<a href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<Input.Search
|
||||
className="searchbar"
|
||||
placeholder="Search queries..."
|
||||
data-test="AppHeaderSearch"
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
<Menu mode="horizontal" selectable={false}>
|
||||
<Menu.Item key="help">
|
||||
<HelpTrigger type="HOME" className="menu-item-button" />
|
||||
</Menu.Item>
|
||||
{currentUser.isAdmin && (
|
||||
<Menu.Item key="settings">
|
||||
<Tooltip title="Settings">
|
||||
<Button href="data_sources" className="menu-item-button">
|
||||
<i className="fa fa-sliders" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="profile">
|
||||
<Dropdown
|
||||
overlayStyle={{ minWidth: 200 }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
overlay={(
|
||||
<Menu>
|
||||
<Menu.Item key="profile">
|
||||
<a href="users/me">Edit Profile</a>
|
||||
</Menu.Item>
|
||||
{currentUser.hasPermission('super_admin') && (
|
||||
<Menu.Divider />
|
||||
)}
|
||||
{currentUser.isAdmin && (
|
||||
<Menu.Item key="datasources">
|
||||
<a href="data_sources">Data Sources</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('list_users') && (
|
||||
<Menu.Item key="groups">
|
||||
<a href="groups">Groups</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('list_users') && (
|
||||
<Menu.Item key="users">
|
||||
<a href="users">Users</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="snippets">
|
||||
<a href="query_snippets">Query Snippets</a>
|
||||
</Menu.Item>
|
||||
{currentUser.hasPermission('list_users') && (
|
||||
<Menu.Item key="destinations">
|
||||
<a href="destinations">Alert Destinations</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('super_admin') && (
|
||||
<Menu.Divider />
|
||||
)}
|
||||
{currentUser.hasPermission('super_admin') && (
|
||||
<Menu.Item key="status">
|
||||
<a href="admin/status">System Status</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>Log out</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="version" disabled>
|
||||
Version: {clientConfig.version}
|
||||
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
|
||||
{clientConfig.newVersionAvailable && currentUser.hasPermission('super_admin') && (
|
||||
<Tooltip title="Update Available" placement="rightTop">
|
||||
{' '}
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
|
||||
<i className="fa fa-arrow-circle-down" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)}
|
||||
>
|
||||
<Button data-test="ProfileDropdown" className="profile-dropdown">
|
||||
<img src={currentUser.profile_image_url} alt={currentUser.name} />
|
||||
<span>{currentUser.name}</span>
|
||||
<Icon type="down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavbar() {
|
||||
const ref = useRef();
|
||||
|
||||
return (
|
||||
<div className="app-header" data-platform="mobile" ref={ref}>
|
||||
<div className="header-logo">
|
||||
<a href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown
|
||||
overlayStyle={{ minWidth: 200 }}
|
||||
trigger={['click']}
|
||||
getPopupContainer={() => ref.current} // so the overlay menu stays with the fixed header when page scrolls
|
||||
overlay={(
|
||||
<Menu mode="vertical" selectable={false}>
|
||||
{currentUser.hasPermission('list_dashboards') && (
|
||||
<Menu.Item key="dashboards">
|
||||
<a href="dashboards">Dashboards</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('view_query') && (
|
||||
<Menu.Item key="queries">
|
||||
<a href="queries">Queries</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('list_alerts') && (
|
||||
<Menu.Item key="alerts">
|
||||
<a href="alerts">Alerts</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="profile">
|
||||
<a href="users/me">Edit Profile</a>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{currentUser.isAdmin && (
|
||||
<Menu.Item key="settings">
|
||||
<a href="data_sources">Settings</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('super_admin') && (
|
||||
<Menu.Item key="status">
|
||||
<a href="admin/status">System Status</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission('super_admin') && (
|
||||
<Menu.Divider />
|
||||
)}
|
||||
<Menu.Item key="help">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href="https://redash.io/help" target="_blank" rel="noopener">Help</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>Log out</Menu.Item>
|
||||
</Menu>
|
||||
)}
|
||||
>
|
||||
<Button><Icon type="menu" /></Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppHeader() {
|
||||
return (
|
||||
<nav className="app-header-wrapper">
|
||||
<DesktopNavbar />
|
||||
<MobileNavbar />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('appHeader', react2angular(AppHeader));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
207
client/app/components/app-header/AppHeader.less
Normal file
207
client/app/components/app-header/AppHeader.less
Normal file
@@ -0,0 +1,207 @@
|
||||
@mobileBreakpoint: ~"(max-width: 767px)";
|
||||
|
||||
nav .app-header {
|
||||
height: 49px;
|
||||
padding-bottom: 1px;
|
||||
box-sizing: content-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, .15);
|
||||
|
||||
.darker {
|
||||
color: #333 !important;
|
||||
|
||||
&:hover {
|
||||
color: #2196F3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
& > * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&[data-platform="mobile"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-item-button {
|
||||
padding: 0 15px;
|
||||
font-size: 18px;
|
||||
.darker();
|
||||
}
|
||||
|
||||
.ant-menu-root {
|
||||
margin: 0 10px;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
font-weight: 500;
|
||||
|
||||
.anticon {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-platform="desktop"] .ant-btn:not(.ant-btn-primary) {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
background-color: transparent; //so it doesn't interfere with click animation of adjacent buttons
|
||||
.darker();
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
padding: 0;
|
||||
height: 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.anticon-down {
|
||||
font-size: 13px !important;
|
||||
transform: none;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
|
||||
svg {
|
||||
transition: transform .2s cubic-bezier(.75,0,.25,1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-open .anticon-down svg,
|
||||
.anticon-down.ant-dropdown-open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-item {
|
||||
.ant-btn {
|
||||
padding-right: 5px;
|
||||
padding-left: 5px;
|
||||
margin-right: 30px;
|
||||
margin-left: 10px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// this is a trick to get the dropdown menu to be placed at the bottom left
|
||||
// of the menu item and not the dropdown trigger
|
||||
.ant-dropdown-trigger {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
left: 10px;
|
||||
bottom: 5px;
|
||||
text-align: right;
|
||||
padding-top: 14px;
|
||||
padding-right: 10px;
|
||||
margin-right: 0;
|
||||
user-select: none; // or else double clicking it causes the header logo to get selected
|
||||
.darker();
|
||||
}
|
||||
}
|
||||
|
||||
.header-logo img {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
width: 185px;
|
||||
}
|
||||
|
||||
.profile-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
max-width: 130px; // arbitrary, prevents layout mess up if username long
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ant-btn,
|
||||
.menu-item-button {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ant-menu-root {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.profile-dropdown {
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.searchbar {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media @mobileBreakpoint {
|
||||
&[data-platform="desktop"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-platform="mobile"] {
|
||||
display: flex;
|
||||
padding: 0 15px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @mobileBreakpoint {
|
||||
.app-header-wrapper {
|
||||
margin-top: 59px !important; // compensate for app header fixed position
|
||||
}
|
||||
}
|
||||
|
||||
.update-available {
|
||||
display: inline !important;
|
||||
|
||||
.fa {
|
||||
color: #52c41a;
|
||||
vertical-align: text-bottom;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item .help-trigger {
|
||||
display: inline;
|
||||
color: #2196F3;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu.favorites-dropdown {
|
||||
margin-left: -10px;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.menu-search input[type="text"] {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.dropdown-menu__version {
|
||||
padding: 5px 10px 8px 17px;
|
||||
}
|
||||
|
||||
.update-available .fa {
|
||||
color: #52c41a;
|
||||
vertical-align: bottom;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
<nav class="navbar navbar-default app-header" role="navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button
|
||||
type="button"
|
||||
class="navbar-toggle"
|
||||
ng-click="isNavOpen = !isNavOpen"
|
||||
>
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<!-- REDASH LOGO -->
|
||||
<a class="navbar-brand" ng-href="{{ $ctrl.basePath }}"
|
||||
><img ng-src="{{ $ctrl.logoUrl }}"
|
||||
/></a>
|
||||
</div>
|
||||
<div class="collapse navbar-collapse" uib-collapse="!isNavOpen">
|
||||
<!-- Main Left Nav-->
|
||||
|
||||
<ul class="nav navbar-nav nav__main">
|
||||
<li
|
||||
class="dropdown btn-group"
|
||||
ng-show="$ctrl.showDashboardsMenu"
|
||||
uib-dropdown
|
||||
>
|
||||
<a class="btn" href="dashboards">Dashboards</a>
|
||||
<a type="button" class="btn hidden-xs" uib-dropdown-toggle>
|
||||
<span class="caret caret--nav"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu>
|
||||
<li ng-if="$ctrl.dashboards.length == 0">
|
||||
<a>
|
||||
<em>
|
||||
<span class="btn-favourite">
|
||||
<i class="fa fa-star" aria-hidden="true"></i>
|
||||
</span>
|
||||
Favorite Dashboards will appear here
|
||||
</em>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-repeat="dashboard in $ctrl.dashboards">
|
||||
<a href="dashboard/{{ dashboard.slug }}">
|
||||
{{ dashboard.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li
|
||||
class="dropdown btn-group"
|
||||
ng-show="$ctrl.showQueriesMenu"
|
||||
uib-dropdown
|
||||
>
|
||||
<a class="btn" href="queries">Queries</a>
|
||||
<a type="button" class="btn hidden-xs" uib-dropdown-toggle>
|
||||
<span class="caret caret--nav"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu>
|
||||
<li ng-if="$ctrl.queries.length == 0">
|
||||
<a>
|
||||
<em>
|
||||
<span class="btn-favourite">
|
||||
<i class="fa fa-star" aria-hidden="true"></i>
|
||||
</span>
|
||||
Favorite Queries will appear here
|
||||
</em>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-repeat="query in $ctrl.queries">
|
||||
<a href="queries/{{ query.id }}">
|
||||
{{ query.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li ng-if="$ctrl.showAlertsLink">
|
||||
<a href="alerts">Alerts</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Add New Button -->
|
||||
<div
|
||||
class="btn-group navbar-btn navbar-left btn__new hidden-xs"
|
||||
uib-dropdown
|
||||
is-open="status.isopen"
|
||||
>
|
||||
<button
|
||||
id="create-button"
|
||||
data-test="CreateButton"
|
||||
type="button"
|
||||
class="btn btn-primary btn--create"
|
||||
uib-dropdown-toggle
|
||||
ng-disabled="disabled"
|
||||
>
|
||||
Create <span class="caret caret--nav"></span>
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
uib-dropdown-menu
|
||||
role="menu"
|
||||
aria-labelledby="create-button"
|
||||
>
|
||||
<li role="menuitem" ng-show="$ctrl.showNewQueryMenu">
|
||||
<a href="queries/new">Query</a>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<a
|
||||
ng-show="$ctrl.currentUser.hasPermission('create_dashboard')"
|
||||
ng-click="$ctrl.newDashboard()"
|
||||
>Dashboard</a
|
||||
>
|
||||
</li>
|
||||
<li role="menuitem"><a href="alerts/new">Alert</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Profile -->
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li>
|
||||
<help-trigger
|
||||
type="'HOME'"
|
||||
class-name="'navbar-link-ANGULAR_REMOVE_ME'"
|
||||
></help-trigger>
|
||||
</li>
|
||||
<li ng-show="$ctrl.currentUser.isAdmin">
|
||||
<a href="data_sources" title="Settings"
|
||||
><i class="fa fa-sliders" aria-hidden="true"></i
|
||||
></a>
|
||||
</li>
|
||||
<!--<li ng-show="$ctrl.showSettingsMenu">-->
|
||||
<!--<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
|
||||
data-test="ProfileDropdown"
|
||||
>
|
||||
<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>
|
||||
<ul class="dropdown-menu dropdown-menu--profile">
|
||||
<li>
|
||||
<a ng-href="users/me">Edit Profile</a>
|
||||
</li>
|
||||
<li
|
||||
class="divider"
|
||||
ng-if="$ctrl.currentUser.hasPermission('super_admin')"
|
||||
></li>
|
||||
|
||||
<li ng-show="$ctrl.currentUser.isAdmin">
|
||||
<a href="data_sources" title="Data Sources">Data Sources</a>
|
||||
</li>
|
||||
<li ng-show="$ctrl.showSettingsMenu">
|
||||
<a href="groups" title="Settings">Groups</a>
|
||||
</li>
|
||||
<li ng-show="$ctrl.showSettingsMenu">
|
||||
<a href="users" title="Settings">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a ng-href="query_snippets">Query Snippets</a>
|
||||
</li>
|
||||
<li ng-show="$ctrl.showSettingsMenu">
|
||||
<a href="destinations" title="Settings">Alert Destinations</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
ng-if="$ctrl.currentUser.hasPermission('super_admin')"
|
||||
class="divider"
|
||||
></li>
|
||||
|
||||
<li ng-if="$ctrl.currentUser.hasPermission('super_admin')">
|
||||
<a href="admin/status">System Status</a>
|
||||
</li>
|
||||
|
||||
<li class="divider"></li>
|
||||
|
||||
<li>
|
||||
<a ng-click="$ctrl.logout()">Log out</a>
|
||||
</li>
|
||||
|
||||
<li class="divider"></li>
|
||||
<li class="dropdown-menu__version">
|
||||
Version: {{ $ctrl.backendVersion }}
|
||||
<span ng-if="$ctrl.frontendVersion !== $ctrl.backendVersion">
|
||||
({{ $ctrl.frontendVersion.substring(0, 8) }})
|
||||
</span>
|
||||
<span
|
||||
class="update-available"
|
||||
ng-if="$ctrl.currentUser.hasPermission('super_admin') && $ctrl.newVersionAvailable"
|
||||
>
|
||||
<a href="https://version.redash.io/" target="_blank">
|
||||
<i class="fa fa-arrow-circle-down"></i>
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 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.searchTerm"
|
||||
class="form-control navbar__search__input"
|
||||
placeholder="Search queries..."
|
||||
data-test="AppHeaderSearch"
|
||||
/>
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-default">
|
||||
<span class="zmdi zmdi-search"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEmpty, template } from 'lodash';
|
||||
|
||||
import Dropdown from 'antd/lib/dropdown';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import Menu from 'antd/lib/menu';
|
||||
|
||||
import HelpTrigger from '@/components/HelpTrigger';
|
||||
|
||||
export default function FavoritesDropdown({ fetch, urlTemplate }) {
|
||||
const [items, setItems] = useState();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const noItems = isEmpty(items);
|
||||
const urlCompiled = useMemo(() => template(urlTemplate), [urlTemplate]);
|
||||
|
||||
const fetchItems = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch().$promise
|
||||
.then(({ results }) => {
|
||||
setItems(results);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [fetch]);
|
||||
|
||||
// fetch items on init
|
||||
useEffect(fetchItems, []);
|
||||
|
||||
// fetch items on click
|
||||
const onVisibleChange = visible => visible && fetchItems();
|
||||
|
||||
const menu = (
|
||||
<Menu className="favorites-dropdown">
|
||||
{noItems ? (
|
||||
<Menu.Item>
|
||||
<span className="btn-favourite m-r-5">
|
||||
<i className="fa fa-star" />
|
||||
</span>
|
||||
No favorites selected yet <HelpTrigger type="FAVORITES" />
|
||||
</Menu.Item>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<Menu.Item key={item.id}>
|
||||
<a href={urlCompiled(item)}>
|
||||
<span className="btn-favourite m-r-5">
|
||||
<i className="fa fa-star" />
|
||||
</span>
|
||||
{item.name}
|
||||
</a>
|
||||
</Menu.Item>
|
||||
))
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown disabled={loading} trigger={['click']} placement="bottomLeft" onVisibleChange={onVisibleChange} overlay={menu}>
|
||||
{loading ? <Icon type="loading" spin /> : <Icon type="down" />}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
FavoritesDropdown.propTypes = {
|
||||
fetch: PropTypes.func.isRequired,
|
||||
urlTemplate: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import debug from 'debug';
|
||||
import CreateDashboardDialog from '@/components/dashboards/CreateDashboardDialog';
|
||||
|
||||
import logoUrl from '@/assets/images/redash_icon_small.png';
|
||||
import frontendVersion from '@/version.json';
|
||||
import template from './app-header.html';
|
||||
import './app-header.css';
|
||||
|
||||
const logger = debug('redash:appHeader');
|
||||
|
||||
function controller($rootScope, $location, $route, $uibModal, Auth, currentUser, clientConfig, Dashboard, Query) {
|
||||
this.logoUrl = logoUrl;
|
||||
this.basePath = clientConfig.basePath;
|
||||
this.currentUser = currentUser;
|
||||
this.showQueriesMenu = currentUser.hasPermission('view_query');
|
||||
this.showAlertsLink = currentUser.hasPermission('list_alerts');
|
||||
this.showNewQueryMenu = currentUser.hasPermission('create_query');
|
||||
this.showSettingsMenu = currentUser.hasPermission('list_users');
|
||||
this.showDashboardsMenu = currentUser.hasPermission('list_dashboards');
|
||||
|
||||
this.frontendVersion = frontendVersion;
|
||||
this.backendVersion = clientConfig.version;
|
||||
this.newVersionAvailable = clientConfig.newVersionAvailable && currentUser.isAdmin;
|
||||
|
||||
this.reload = () => {
|
||||
logger('Reloading dashboards and queries.');
|
||||
Dashboard.favorites().$promise.then((data) => {
|
||||
this.dashboards = data.results;
|
||||
});
|
||||
Query.favorites().$promise.then((data) => {
|
||||
this.queries = data.results;
|
||||
});
|
||||
};
|
||||
|
||||
this.reload();
|
||||
|
||||
$rootScope.$on('reloadFavorites', this.reload);
|
||||
|
||||
this.newDashboard = () => CreateDashboardDialog.showModal();
|
||||
|
||||
this.searchQueries = () => {
|
||||
$location.path('/queries').search({ q: this.searchTerm });
|
||||
$route.reload();
|
||||
};
|
||||
|
||||
this.logout = () => {
|
||||
Auth.logout();
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('appHeader', {
|
||||
template,
|
||||
controller,
|
||||
});
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -4,6 +4,8 @@ import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import EmptyState from '@/components/items-list/components/EmptyState';
|
||||
|
||||
import './CardsList.less';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
export default class CardsList extends React.Component {
|
||||
|
||||
76
client/app/components/cards-list/CardsList.less
Normal file
76
client/app/components/cards-list/CardsList.less
Normal file
@@ -0,0 +1,76 @@
|
||||
|
||||
@import '../../assets/less/inc/variables';
|
||||
|
||||
.visual-card-list {
|
||||
margin: -5px 0 0 -5px; // compensate for .visual-card spacing
|
||||
}
|
||||
|
||||
.visual-card {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid fade(@redash-gray, 15%);
|
||||
border-radius: 3px;
|
||||
margin: 5px;
|
||||
width: 212px;
|
||||
padding: 15px 5px;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
transition: transform 0.12s ease-out;
|
||||
transition-duration: 0.3s;
|
||||
transition-property: box-shadow;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
box-shadow: rgba(102, 136, 153, 0.15) 0px 4px 9px -3px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
color: #323232;
|
||||
margin: 0 !important;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.visual-card {
|
||||
width: 217px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 755px) {
|
||||
.visual-card {
|
||||
width: 47%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 515px) {
|
||||
.visual-card {
|
||||
width: 47%;
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 408px) {
|
||||
.visual-card {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,10 @@
|
||||
@import '../assets/less/inc/variables';
|
||||
|
||||
// ANGULAR_REMOVE_ME
|
||||
color-box {
|
||||
vertical-align: text-bottom;
|
||||
display: inline;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
|
||||
span {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
display: inline-block !important;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
& ~ span {
|
||||
& ~ span {
|
||||
vertical-align: bottom;
|
||||
color: @input-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { includes, reduce, some } from 'lodash';
|
||||
const WIDGET_SELECTOR = '[data-widgetid="{0}"]';
|
||||
const WIDGET_CONTENT_SELECTOR = [
|
||||
'.widget-header', // header
|
||||
'visualization-renderer', // visualization
|
||||
'.visualization-renderer', // visualization
|
||||
'.scrollbox .alert', // error state
|
||||
'.spinner-container', // loading state
|
||||
'.tile__bottom-control', // footer
|
||||
|
||||
@@ -4,10 +4,11 @@ import { chain, cloneDeep, find } from 'lodash';
|
||||
import { react2angular } from 'react2angular';
|
||||
import cx from 'classnames';
|
||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||
import { DashboardWidget } from '@/components/dashboards/widget';
|
||||
import { VisualizationWidget, TextboxWidget, RestrictedWidget } from '@/components/dashboards/dashboard-widget';
|
||||
import { FiltersType } from '@/components/Filters';
|
||||
import cfg from '@/config/dashboard-grid-options';
|
||||
import AutoHeightController from './AutoHeightController';
|
||||
import { WidgetTypeEnum } from '@/services/widget';
|
||||
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import './dashboard-grid.less';
|
||||
@@ -41,16 +42,22 @@ class DashboardGrid extends React.Component {
|
||||
widgets: PropTypes.arrayOf(WidgetType).isRequired,
|
||||
filters: FiltersType,
|
||||
onBreakpointChange: PropTypes.func,
|
||||
onLoadWidget: PropTypes.func,
|
||||
onRefreshWidget: PropTypes.func,
|
||||
onRemoveWidget: PropTypes.func,
|
||||
onLayoutChange: PropTypes.func,
|
||||
onParameterMappingsChange: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
isPublic: false,
|
||||
filters: [],
|
||||
onLoadWidget: () => {},
|
||||
onRefreshWidget: () => {},
|
||||
onRemoveWidget: () => {},
|
||||
onLayoutChange: () => {},
|
||||
onBreakpointChange: () => {},
|
||||
onParameterMappingsChange: () => {},
|
||||
};
|
||||
|
||||
static normalizeFrom(widget) {
|
||||
@@ -168,7 +175,8 @@ class DashboardGrid extends React.Component {
|
||||
|
||||
render() {
|
||||
const className = cx('dashboard-wrapper', this.props.isEditing ? 'editing-mode' : 'preview-mode');
|
||||
const { onRemoveWidget, dashboard, widgets } = this.props;
|
||||
const { onLoadWidget, onRefreshWidget, onRemoveWidget,
|
||||
onParameterMappingsChange, filters, dashboard, isPublic, widgets } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -186,23 +194,37 @@ class DashboardGrid extends React.Component {
|
||||
onBreakpointChange={this.onBreakpointChange}
|
||||
breakpoints={{ [MULTI]: cfg.mobileBreakPoint, [SINGLE]: 0 }}
|
||||
>
|
||||
{widgets.map(widget => (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-grid={DashboardGrid.normalizeFrom(widget)}
|
||||
data-widgetid={widget.id}
|
||||
data-test={`WidgetId${widget.id}`}
|
||||
className={cx('dashboard-widget-wrapper', { 'widget-auto-height-enabled': this.autoHeightCtrl.exists(widget.id) })}
|
||||
>
|
||||
<DashboardWidget
|
||||
widget={widget}
|
||||
dashboard={dashboard}
|
||||
filters={this.props.filters}
|
||||
deleted={() => onRemoveWidget(widget.id)}
|
||||
public={this.props.isPublic}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{widgets.map((widget) => {
|
||||
const widgetProps = {
|
||||
widget,
|
||||
filters,
|
||||
isPublic,
|
||||
canEdit: dashboard.canEdit(),
|
||||
onDelete: () => onRemoveWidget(widget.id),
|
||||
};
|
||||
const { type } = widget;
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-grid={DashboardGrid.normalizeFrom(widget)}
|
||||
data-widgetid={widget.id}
|
||||
data-test={`WidgetId${widget.id}`}
|
||||
className={cx('dashboard-widget-wrapper', { 'widget-auto-height-enabled': this.autoHeightCtrl.exists(widget.id) })}
|
||||
>
|
||||
{type === WidgetTypeEnum.VISUALIZATION && (
|
||||
<VisualizationWidget
|
||||
{...widgetProps}
|
||||
dashboard={dashboard}
|
||||
onLoad={() => onLoadWidget(widget)}
|
||||
onRefresh={() => onRefreshWidget(widget)}
|
||||
onParameterMappingsChange={onParameterMappingsChange}
|
||||
/>
|
||||
)}
|
||||
{type === WidgetTypeEnum.TEXTBOX && <TextboxWidget {...widgetProps} />}
|
||||
{type === WidgetTypeEnum.RESTRICTED && <RestrictedWidget widget={widget} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGridLayout>
|
||||
</div>
|
||||
);
|
||||
|
||||
36
client/app/components/dashboards/ExpandedWidgetDialog.jsx
Normal file
36
client/app/components/dashboards/ExpandedWidgetDialog.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from 'antd/lib/button';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import { VisualizationRenderer } from '@/visualizations/VisualizationRenderer';
|
||||
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
|
||||
import { VisualizationName } from '@/visualizations/VisualizationName';
|
||||
|
||||
function ExpandedWidgetDialog({ dialog, widget }) {
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title={(
|
||||
<>
|
||||
<VisualizationName visualization={widget.visualization} />{' '}
|
||||
<span>{widget.getQuery().name}</span>
|
||||
</>
|
||||
)}
|
||||
width="95%"
|
||||
footer={(<Button onClick={dialog.dismiss}>Close</Button>)}
|
||||
>
|
||||
<VisualizationRenderer
|
||||
visualization={widget.visualization}
|
||||
queryResult={widget.getQueryResult()}
|
||||
context="widget"
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
ExpandedWidgetDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
export default wrapDialog(ExpandedWidgetDialog);
|
||||
@@ -14,7 +14,6 @@ import './TextboxDialog.less';
|
||||
|
||||
class TextboxDialog extends React.Component {
|
||||
static propTypes = {
|
||||
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dialog: DialogPropType.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
text: PropTypes.string,
|
||||
|
||||
@@ -5,3 +5,32 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// react-grid-layout overrides
|
||||
.react-grid-item {
|
||||
|
||||
// placeholder color
|
||||
&.react-grid-placeholder {
|
||||
border-radius: 3px;
|
||||
background-color: #E0E6EB;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// resize placeholder behind widget, the lib's default is above 🤷♂️
|
||||
&.resizing {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
// auto-height animation
|
||||
&.cssTransforms:not(.resizing) {
|
||||
transition-property: transform, height; // added ", height"
|
||||
}
|
||||
|
||||
// resize handle size
|
||||
& > .react-resizable-handle::after {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import Widget from './Widget';
|
||||
|
||||
function RestrictedWidget(props) {
|
||||
return (
|
||||
<Widget {...props} className="d-flex justify-content-center align-items-center widget-restricted">
|
||||
<div className="t-body scrollbox">
|
||||
<div className="text-center">
|
||||
<h1><span className="zmdi zmdi-lock" /></h1>
|
||||
<p className="text-muted">
|
||||
This widget requires access to a data source you don't have access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
export default RestrictedWidget;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { markdown } from 'markdown';
|
||||
import Menu from 'antd/lib/menu';
|
||||
import HtmlContent from '@/components/HtmlContent';
|
||||
import TextboxDialog from '@/components/dashboards/TextboxDialog';
|
||||
import Widget from './Widget';
|
||||
|
||||
function TextboxWidget(props) {
|
||||
const { widget, canEdit } = props;
|
||||
const [text, setText] = useState(widget.text);
|
||||
|
||||
const editTextBox = () => {
|
||||
TextboxDialog.showModal({
|
||||
text: widget.text,
|
||||
onConfirm: (newText) => {
|
||||
widget.text = newText;
|
||||
setText(newText);
|
||||
return widget.save();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const TextboxMenuOptions = [
|
||||
<Menu.Item key="edit" onClick={editTextBox}>Edit</Menu.Item>,
|
||||
];
|
||||
|
||||
if (!widget.width) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget {...props} menuOptions={canEdit ? TextboxMenuOptions : null} className="widget-text">
|
||||
<HtmlContent className="body-row-auto scrollbox t-body p-15 markdown">
|
||||
{markdown.toHTML(text || '')}
|
||||
</HtmlContent>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
TextboxWidget.propTypes = {
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
canEdit: PropTypes.bool,
|
||||
};
|
||||
|
||||
TextboxWidget.defaultProps = {
|
||||
canEdit: false,
|
||||
};
|
||||
|
||||
export default TextboxWidget;
|
||||
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { compact, isEmpty, invoke } from "lodash";
|
||||
import { markdown } from "markdown";
|
||||
import cx from "classnames";
|
||||
import Menu from "antd/lib/menu";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
import { formatDateTime } from "@/filters/datetime";
|
||||
import HtmlContent from "@/components/HtmlContent";
|
||||
import { Parameters } from "@/components/Parameters";
|
||||
import { TimeAgo } from "@/components/TimeAgo";
|
||||
import { Timer } from "@/components/Timer";
|
||||
import { Moment } from "@/components/proptypes";
|
||||
import QueryLink from "@/components/QueryLink";
|
||||
import { FiltersType } from "@/components/Filters";
|
||||
import ExpandedWidgetDialog from "@/components/dashboards/ExpandedWidgetDialog";
|
||||
import EditParameterMappingsDialog from "@/components/dashboards/EditParameterMappingsDialog";
|
||||
import { VisualizationRenderer } from "@/visualizations/VisualizationRenderer";
|
||||
import Widget from "./Widget";
|
||||
|
||||
function visualizationWidgetMenuOptions({
|
||||
widget,
|
||||
canEditDashboard,
|
||||
onParametersEdit,
|
||||
}) {
|
||||
const canViewQuery = currentUser.hasPermission("view_query");
|
||||
const canEditParameters =
|
||||
canEditDashboard && !isEmpty(invoke(widget, "query.getParametersDefs"));
|
||||
const widgetQueryResult = widget.getQueryResult();
|
||||
const isQueryResultEmpty =
|
||||
!widgetQueryResult ||
|
||||
!widgetQueryResult.isEmpty ||
|
||||
widgetQueryResult.isEmpty();
|
||||
|
||||
const downloadLink = (fileType) =>
|
||||
widgetQueryResult.getLink(widget.getQuery().id, fileType);
|
||||
const downloadName = (fileType) =>
|
||||
widgetQueryResult.getName(widget.getQuery().name, fileType);
|
||||
return compact([
|
||||
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
|
||||
{!isQueryResultEmpty ? (
|
||||
<a
|
||||
href={downloadLink("csv")}
|
||||
download={downloadName("csv")}
|
||||
target="_self"
|
||||
>
|
||||
Download as CSV File
|
||||
</a>
|
||||
) : (
|
||||
"Download as CSV File"
|
||||
)}
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
|
||||
{!isQueryResultEmpty ? (
|
||||
<a
|
||||
href={downloadLink("xlsx")}
|
||||
download={downloadName("xlsx")}
|
||||
target="_self"
|
||||
>
|
||||
Download as Excel File
|
||||
</a>
|
||||
) : (
|
||||
"Download as Excel File"
|
||||
)}
|
||||
</Menu.Item>,
|
||||
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
|
||||
canViewQuery && (
|
||||
<Menu.Item key="view_query">
|
||||
<a href={widget.getQuery().getUrl(true, widget.visualization.id)}>
|
||||
View Query
|
||||
</a>
|
||||
</Menu.Item>
|
||||
),
|
||||
canEditParameters && (
|
||||
<Menu.Item key="edit_parameters" onClick={onParametersEdit}>
|
||||
Edit Parameters
|
||||
</Menu.Item>
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
function RefreshIndicator({ refreshStartedAt }) {
|
||||
return (
|
||||
<div className="refresh-indicator">
|
||||
<div className="refresh-icon">
|
||||
<i className="zmdi zmdi-refresh zmdi-hc-spin" />
|
||||
</div>
|
||||
<Timer from={refreshStartedAt} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RefreshIndicator.propTypes = { refreshStartedAt: Moment };
|
||||
RefreshIndicator.defaultProps = { refreshStartedAt: null };
|
||||
|
||||
function VisualizationWidgetHeader({
|
||||
widget,
|
||||
refreshStartedAt,
|
||||
parameters,
|
||||
onParametersUpdate,
|
||||
}) {
|
||||
const canViewQuery = currentUser.hasPermission("view_query");
|
||||
const queryResult = widget.getQueryResult();
|
||||
const errorData = queryResult && queryResult.getErrorData();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RefreshIndicator refreshStartedAt={refreshStartedAt} />
|
||||
<div className="t-header widget clearfix">
|
||||
<div className="th-title">
|
||||
<p>
|
||||
<QueryLink
|
||||
query={widget.getQuery()}
|
||||
visualization={widget.visualization}
|
||||
readOnly={!canViewQuery}
|
||||
/>
|
||||
</p>
|
||||
<HtmlContent className="text-muted markdown query--description">
|
||||
{markdown.toHTML(widget.getQuery().description || "")}
|
||||
</HtmlContent>
|
||||
</div>
|
||||
</div>
|
||||
{!isEmpty(parameters) && (
|
||||
<div className="m-b-5">
|
||||
<Parameters
|
||||
parameters={parameters}
|
||||
queryResultErrorData={errorData}
|
||||
onValuesChange={onParametersUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
VisualizationWidgetHeader.propTypes = {
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
refreshStartedAt: Moment,
|
||||
parameters: PropTypes.arrayOf(PropTypes.object),
|
||||
onParametersUpdate: PropTypes.func,
|
||||
};
|
||||
|
||||
VisualizationWidgetHeader.defaultProps = {
|
||||
refreshStartedAt: null,
|
||||
onParametersUpdate: () => {},
|
||||
parameters: [],
|
||||
};
|
||||
|
||||
function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) {
|
||||
const widgetQueryResult = widget.getQueryResult();
|
||||
const updatedAt = invoke(widgetQueryResult, "getUpdatedAt");
|
||||
const [refreshClickButtonId, setRefreshClickButtonId] = useState();
|
||||
|
||||
const refreshWidget = (buttonId) => {
|
||||
if (!refreshClickButtonId) {
|
||||
setRefreshClickButtonId(buttonId);
|
||||
onRefresh().finally(() => setRefreshClickButtonId(null));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
{!isPublic && !!widgetQueryResult && (
|
||||
<a
|
||||
className="refresh-button hidden-print btn btn-sm btn-default btn-transparent"
|
||||
onClick={() => refreshWidget(1)}
|
||||
data-test="RefreshButton"
|
||||
>
|
||||
<i
|
||||
className={cx("zmdi zmdi-refresh", {
|
||||
"zmdi-hc-spin": refreshClickButtonId === 1,
|
||||
})}
|
||||
/>{" "}
|
||||
<TimeAgo date={updatedAt} />
|
||||
</a>
|
||||
)}
|
||||
<span className="visible-print">
|
||||
<i className="zmdi zmdi-time-restore" /> {formatDateTime(updatedAt)}
|
||||
</span>
|
||||
{isPublic && (
|
||||
<span className="small hidden-print">
|
||||
<i className="zmdi zmdi-time-restore" />{" "}
|
||||
<TimeAgo date={updatedAt} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{!isPublic && (
|
||||
<a
|
||||
className="btn btn-sm btn-default hidden-print btn-transparent btn__refresh"
|
||||
onClick={() => refreshWidget(2)}
|
||||
>
|
||||
<i
|
||||
className={cx("zmdi zmdi-refresh", {
|
||||
"zmdi-hc-spin": refreshClickButtonId === 2,
|
||||
})}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
className="btn btn-sm btn-default hidden-print btn-transparent btn__refresh"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<i className="zmdi zmdi-fullscreen" />
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
VisualizationWidgetFooter.propTypes = {
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
isPublic: PropTypes.bool,
|
||||
onRefresh: PropTypes.func.isRequired,
|
||||
onExpand: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
VisualizationWidgetFooter.defaultProps = { isPublic: false };
|
||||
|
||||
class VisualizationWidget extends React.Component {
|
||||
static propTypes = {
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
filters: FiltersType,
|
||||
isPublic: PropTypes.bool,
|
||||
canEdit: PropTypes.bool,
|
||||
onLoad: PropTypes.func,
|
||||
onRefresh: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onParameterMappingsChange: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
filters: [],
|
||||
isPublic: false,
|
||||
canEdit: false,
|
||||
onLoad: () => {},
|
||||
onRefresh: () => {},
|
||||
onDelete: () => {},
|
||||
onParameterMappingsChange: () => {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { localParameters: props.widget.getLocalParameters() };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { widget, onLoad } = this.props;
|
||||
recordEvent("view", "query", widget.visualization.query.id, {
|
||||
dashboard: true,
|
||||
});
|
||||
recordEvent("view", "visualization", widget.visualization.id, {
|
||||
dashboard: true,
|
||||
});
|
||||
onLoad();
|
||||
}
|
||||
|
||||
expandWidget = () => {
|
||||
ExpandedWidgetDialog.showModal({ widget: this.props.widget });
|
||||
};
|
||||
|
||||
editParameterMappings = () => {
|
||||
const { widget, dashboard, onRefresh, onParameterMappingsChange } =
|
||||
this.props;
|
||||
EditParameterMappingsDialog.showModal({
|
||||
dashboard,
|
||||
widget,
|
||||
}).result.then((valuesChanged) => {
|
||||
// refresh widget if any parameter value has been updated
|
||||
if (valuesChanged) {
|
||||
onRefresh();
|
||||
}
|
||||
onParameterMappingsChange();
|
||||
this.setState({ localParameters: widget.getLocalParameters() });
|
||||
});
|
||||
};
|
||||
|
||||
renderVisualization() {
|
||||
const { widget, filters } = this.props;
|
||||
const widgetQueryResult = widget.getQueryResult();
|
||||
const widgetStatus = widgetQueryResult && widgetQueryResult.getStatus();
|
||||
switch (widgetStatus) {
|
||||
case "failed":
|
||||
return (
|
||||
<div className="body-row-auto scrollbox">
|
||||
{widgetQueryResult.getError() && (
|
||||
<div className="alert alert-danger m-5">
|
||||
Error running query:{" "}
|
||||
<strong>{widgetQueryResult.getError()}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "done":
|
||||
return (
|
||||
<div className="body-row-auto scrollbox">
|
||||
<VisualizationRenderer
|
||||
visualization={widget.visualization}
|
||||
queryResult={widgetQueryResult}
|
||||
filters={filters}
|
||||
context="widget"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="body-row-auto spinner-container">
|
||||
<div className="spinner">
|
||||
<i className="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { widget, isPublic, canEdit, onRefresh } = this.props;
|
||||
const { localParameters } = this.state;
|
||||
const widgetQueryResult = widget.getQueryResult();
|
||||
const isRefreshing =
|
||||
widget.loading && !!(widgetQueryResult && widgetQueryResult.getStatus());
|
||||
|
||||
return (
|
||||
<Widget
|
||||
{...this.props}
|
||||
className="widget-visualization"
|
||||
menuOptions={visualizationWidgetMenuOptions({
|
||||
widget,
|
||||
canEditDashboard: canEdit,
|
||||
onParametersEdit: this.editParameterMappings,
|
||||
})}
|
||||
header={
|
||||
<VisualizationWidgetHeader
|
||||
widget={widget}
|
||||
refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null}
|
||||
parameters={localParameters}
|
||||
onParametersUpdate={onRefresh}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<VisualizationWidgetFooter
|
||||
widget={widget}
|
||||
isPublic={isPublic}
|
||||
onRefresh={onRefresh}
|
||||
onExpand={this.expandWidget}
|
||||
/>
|
||||
}
|
||||
tileProps={{ "data-refreshing": isRefreshing }}
|
||||
>
|
||||
{this.renderVisualization()}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default VisualizationWidget;
|
||||
141
client/app/components/dashboards/dashboard-widget/Widget.jsx
Normal file
141
client/app/components/dashboards/dashboard-widget/Widget.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash';
|
||||
import Dropdown from 'antd/lib/dropdown';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Menu from 'antd/lib/menu';
|
||||
import recordEvent from '@/services/recordEvent';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
|
||||
import './Widget.less';
|
||||
|
||||
function WidgetDropdownButton({ extraOptions, showDeleteOption, onDelete }) {
|
||||
const WidgetMenu = (
|
||||
<Menu data-test="WidgetDropdownButtonMenu">
|
||||
{extraOptions}
|
||||
{(showDeleteOption && extraOptions) && <Menu.Divider />}
|
||||
{showDeleteOption && <Menu.Item onClick={onDelete}>Remove from Dashboard</Menu.Item>}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="widget-menu-regular">
|
||||
<Dropdown
|
||||
overlay={WidgetMenu}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<a className="action p-l-15 p-r-15" data-test="WidgetDropdownButton">
|
||||
<i className="zmdi zmdi-more-vert" />
|
||||
</a>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
WidgetDropdownButton.propTypes = {
|
||||
extraOptions: PropTypes.node,
|
||||
showDeleteOption: PropTypes.bool,
|
||||
onDelete: PropTypes.func,
|
||||
};
|
||||
|
||||
WidgetDropdownButton.defaultProps = {
|
||||
extraOptions: null,
|
||||
showDeleteOption: false,
|
||||
onDelete: () => {},
|
||||
};
|
||||
|
||||
function WidgetDeleteButton({ onClick }) {
|
||||
return (
|
||||
<div className="widget-menu-remove">
|
||||
<a className="action" title="Remove From Dashboard" onClick={onClick} data-test="WidgetDeleteButton">
|
||||
<i className="zmdi zmdi-close" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
WidgetDeleteButton.propTypes = { onClick: PropTypes.func };
|
||||
WidgetDeleteButton.defaultProps = { onClick: () => {} };
|
||||
|
||||
class Widget extends React.Component {
|
||||
static propTypes = {
|
||||
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
header: PropTypes.node,
|
||||
footer: PropTypes.node,
|
||||
canEdit: PropTypes.bool,
|
||||
isPublic: PropTypes.bool,
|
||||
refreshStartedAt: Moment,
|
||||
menuOptions: PropTypes.node,
|
||||
tileProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
onDelete: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
children: null,
|
||||
header: null,
|
||||
footer: null,
|
||||
canEdit: false,
|
||||
isPublic: false,
|
||||
refreshStartedAt: null,
|
||||
menuOptions: null,
|
||||
tileProps: {},
|
||||
onDelete: () => {},
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { widget } = this.props;
|
||||
recordEvent('view', 'widget', widget.id);
|
||||
}
|
||||
|
||||
deleteWidget = () => {
|
||||
const { widget, onDelete } = this.props;
|
||||
|
||||
Modal.confirm({
|
||||
title: 'Delete Widget',
|
||||
content: 'Are you sure you want to remove this widget from the dashboard?',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
onOk: () => widget.delete().then(onDelete),
|
||||
maskClosable: true,
|
||||
autoFocusButton: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, children, header, footer, canEdit, isPublic,
|
||||
menuOptions, tileProps } = this.props;
|
||||
const showDropdownButton = !isPublic && (canEdit || !isEmpty(menuOptions));
|
||||
return (
|
||||
<div className="widget-wrapper">
|
||||
<div className={cx('tile body-container', className)} {...tileProps}>
|
||||
<div className="widget-actions">
|
||||
{showDropdownButton && (
|
||||
<WidgetDropdownButton
|
||||
extraOptions={menuOptions}
|
||||
showDeleteOption={canEdit}
|
||||
onDelete={this.deleteWidget}
|
||||
/>
|
||||
)}
|
||||
{canEdit && <WidgetDeleteButton onClick={this.deleteWidget} />}
|
||||
</div>
|
||||
<div className="body-row widget-header">
|
||||
{header}
|
||||
</div>
|
||||
{children}
|
||||
{footer && (
|
||||
<div className="body-row tile__bottom-control">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Widget;
|
||||
253
client/app/components/dashboards/dashboard-widget/Widget.less
Normal file
253
client/app/components/dashboards/dashboard-widget/Widget.less
Normal file
@@ -0,0 +1,253 @@
|
||||
@import '../../../assets/less/inc/variables';
|
||||
|
||||
.tile .t-header .th-title a.query-link {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.th-title p.hidden-print {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.widget-wrapper {
|
||||
.widget-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
|
||||
.action {
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
line-height: 100%;
|
||||
display: block;
|
||||
padding: 4px 10px 3px;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-container {
|
||||
margin: 0 15px;
|
||||
}
|
||||
|
||||
.body-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.body-row {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.body-row-auto {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
position: relative;
|
||||
|
||||
.spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbox:empty {
|
||||
padding: 0 !important;
|
||||
font-size: 1px !important;
|
||||
}
|
||||
|
||||
.widget-text {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editing-mode {
|
||||
.widget-menu-regular {
|
||||
display: none;
|
||||
}
|
||||
.widget-menu-remove {
|
||||
display: block;
|
||||
}
|
||||
|
||||
a.query-link {
|
||||
pointer-events: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.th-title {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
transition-duration: 0s;
|
||||
|
||||
.rd-timer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.refresh-indicator-mini();
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
font-size: 18px;
|
||||
color: #86a1af;
|
||||
transition: all 100ms linear;
|
||||
transition-delay: 150ms; // waits for widget-menu to fade out before moving back over it
|
||||
transform: translateX(22px);
|
||||
position: absolute;
|
||||
right: 29px;
|
||||
top: 8px;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.refresh-icon {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #e8ecf0;
|
||||
border-radius: 50%;
|
||||
transition: opacity 100ms linear;
|
||||
transition-delay: 150ms;
|
||||
}
|
||||
|
||||
i {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.rd-timer {
|
||||
font-size: 13px;
|
||||
display: inline-block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
transition: all 100ms linear;
|
||||
transition-delay: 150ms;
|
||||
color: #bbbbbb;
|
||||
background-color: rgba(255,255,255,.9);
|
||||
padding-left: 2px;
|
||||
padding-right: 1px;
|
||||
margin-right: -4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.widget-visualization[data-refreshing="false"] & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-indicator-mini() {
|
||||
font-size: 13px;
|
||||
transition-delay: 0s;
|
||||
color: #bbbbbb;
|
||||
transform: translateY(-4px);
|
||||
|
||||
.refresh-icon:before {
|
||||
transition-delay: 0s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.rd-timer {
|
||||
transition-delay: 0s;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tile {
|
||||
.widget-menu-regular, .btn__refresh {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
}
|
||||
|
||||
.t-header {
|
||||
.th-title {
|
||||
padding-right: 23px; // no overlap on RefreshIndicator
|
||||
|
||||
a {
|
||||
color: fade(@redash-black, 80%);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.query--description {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.t-header.widget {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.widget-menu-regular, .btn__refresh {
|
||||
opacity: 1 !important;
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
.refresh-indicator-mini();
|
||||
}
|
||||
}
|
||||
|
||||
.tile__bottom-control {
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.btn-transparent {
|
||||
&:first-child {
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
a {
|
||||
color: fade(@redash-black, 65%);
|
||||
|
||||
&:hover {
|
||||
color: fade(@redash-black, 95%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as VisualizationWidget } from './VisualizationWidget';
|
||||
export { default as TextboxWidget } from './TextboxWidget';
|
||||
export { default as RestrictedWidget } from './RestrictedWidget';
|
||||
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
@@ -1,8 +0,0 @@
|
||||
.visualization-title {
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body.modal-open .dropdown.open {
|
||||
z-index: 10000;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<div class="widget-wrapper">
|
||||
<div class="tile body-container widget-visualization" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
|
||||
ng-switch="$ctrl.widget.getQueryResult().getStatus()" ng-attr-data-refreshing="{{ $ctrl.widget.loading && !!$ctrl.widget.getQueryResult().getStatus() }}">
|
||||
<div class="body-row widget-header">
|
||||
<div class="t-header widget clearfix">
|
||||
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
|
||||
<div class="actions">
|
||||
<a ng-click="$ctrl.deleteWidget()" title="Remove From Dashboard"><i class="zmdi zmdi-close"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public"
|
||||
uib-dropdown dropdown-append-to-body="true"
|
||||
>
|
||||
<div class="actions">
|
||||
<a data-toggle="dropdown" uib-dropdown-toggle class="p-l-15 p-r-15"><i class="zmdi zmdi-more-vert"></i></a>
|
||||
</div>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||
<li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'csv')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'csv')}}" target="_self">Download as CSV File</a></li>
|
||||
<li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'xlsx')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'xlsx')}}" target="_self">Download as Excel File</a></li>
|
||||
|
||||
<li ng-if="$ctrl.canViewQuery || ($ctrl.dashboard.canEdit() && $ctrl.hasParameters())" class="divider"></li>
|
||||
<li ng-if="$ctrl.canViewQuery"><a ng-href="{{$ctrl.widget.getQuery().getUrl(true, $ctrl.widget.visualization.id)}}">View Query</a></li>
|
||||
<li ng-if="$ctrl.dashboard.canEdit() && $ctrl.hasParameters()">
|
||||
<li ng-if="$ctrl.dashboard.canEdit() && $ctrl.hasParameters()"><a ng-click="$ctrl.editParameterMappings()">Edit Parameters</a></li>
|
||||
</li>
|
||||
|
||||
<li ng-if="$ctrl.dashboard.canEdit()" class="divider"></li>
|
||||
<li ng-if="$ctrl.dashboard.canEdit()"><a ng-click="$ctrl.deleteWidget()">Remove from Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="refresh-indicator" ng-if="$ctrl.widget.loading">
|
||||
<div class="refresh-icon">
|
||||
<i class="zmdi zmdi-refresh zmdi-hc-spin"></i>
|
||||
</div>
|
||||
<rd-timer from="$ctrl.widget.refreshStartedAt"></rd-timer>
|
||||
</div>
|
||||
<div class="th-title">
|
||||
<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>
|
||||
</div>
|
||||
<div class="m-b-10" ng-if="$ctrl.localParametersDefs().length > 0">
|
||||
<parameters parameters="$ctrl.localParametersDefs()" on-values-change="$ctrl.refresh"></parameters>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-switch-when="failed" class="body-row-auto scrollbox">
|
||||
<div class="alert alert-danger m-5" ng-show="$ctrl.widget.getQueryResult().getError()">Error running query: <strong>{{$ctrl.widget.getQueryResult().getError()}}</strong></div>
|
||||
</div>
|
||||
<div ng-switch-when="done" class="body-row-auto scrollbox">
|
||||
<visualization-renderer class="t-body"
|
||||
visualization="$ctrl.widget.visualization"
|
||||
query-result="$ctrl.widget.getQueryResult()"
|
||||
filters="$ctrl.filters"
|
||||
></visualization-renderer>
|
||||
</div>
|
||||
<div ng-switch-default class="body-row-auto spinner-container">
|
||||
<div class="spinner">
|
||||
<i class="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body-row clearfix tile__bottom-control">
|
||||
<a class="refresh-button hidden-print btn btn-sm btn-default btn-transparent" ng-click="$ctrl.refresh(1)" ng-if="!$ctrl.public && !!$ctrl.widget.getQueryResult()" data-test="RefreshButton">
|
||||
<i class="zmdi zmdi-refresh" ng-class="{ 'zmdi-hc-spin': $ctrl.refreshClickButtonId === 1}"></i>
|
||||
<span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
|
||||
</a>
|
||||
<span class="small hidden-print" ng-if="$ctrl.public">
|
||||
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
|
||||
</span>
|
||||
<span class="visible-print">
|
||||
<i class="zmdi zmdi-time-restore"></i> {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}}
|
||||
</span>
|
||||
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.refresh(2)" ng-if="!$ctrl.public">
|
||||
<i class="zmdi zmdi-refresh" ng-class="{ 'zmdi-hc-spin': $ctrl.refreshClickButtonId === 2}"></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>
|
||||
|
||||
<div class="tile body-container d-flex justify-content-center align-items-center widget-restricted" ng-if="$ctrl.type=='restricted'" ng-class="$ctrl.type">
|
||||
<div class="t-body scrollbox">
|
||||
<div class="text-center">
|
||||
<h1><span class="zmdi zmdi-lock"></span></h1>
|
||||
<p class="text-muted">
|
||||
This widget requires access to a data source you don't have access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tile body-container widget-text" ng-hide="$ctrl.widget.width === 0" ng-if="$ctrl.type=='textbox'" ng-class="$ctrl.type">
|
||||
<div class="body-row clearfix t-body">
|
||||
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
|
||||
<div class="dropdown-header">
|
||||
<a class="actions" ng-click="$ctrl.deleteWidget()" title="Remove From Dashboard"><i class="zmdi zmdi-close"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()"
|
||||
uib-dropdown dropdown-append-to-body="true">
|
||||
<div class="dropdown-header">
|
||||
<a data-toggle="dropdown" uib-dropdown-toggle class="actions p-l-15 p-r-15"><i class="zmdi zmdi-more-vert"></i></a>
|
||||
</div>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-right" uib-dropdown-menu style="z-index:1000000">
|
||||
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.editTextBox()">Edit</a></li>
|
||||
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-row-auto scrollbox tiled t-body p-15 markdown" ng-bind-html="$ctrl.widget.text | markdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,135 +0,0 @@
|
||||
import { filter } from 'lodash';
|
||||
import { angular2react } from 'angular2react';
|
||||
import template from './widget.html';
|
||||
import TextboxDialog from '@/components/dashboards/TextboxDialog';
|
||||
import widgetDialogTemplate from './widget-dialog.html';
|
||||
import EditParameterMappingsDialog from '@/components/dashboards/EditParameterMappingsDialog';
|
||||
import './widget.less';
|
||||
import './widget-dialog.less';
|
||||
|
||||
const WidgetDialog = {
|
||||
template: widgetDialogTemplate,
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
controller() {
|
||||
this.widget = this.resolve.widget;
|
||||
},
|
||||
};
|
||||
|
||||
export let DashboardWidget = null; // eslint-disable-line import/no-mutable-exports
|
||||
|
||||
function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope, $timeout, Events, currentUser) {
|
||||
this.canViewQuery = currentUser.hasPermission('view_query');
|
||||
|
||||
this.editTextBox = () => {
|
||||
TextboxDialog.showModal({
|
||||
dashboard: this.dashboard,
|
||||
text: this.widget.text,
|
||||
onConfirm: (text) => {
|
||||
this.widget.text = text;
|
||||
return this.widget.save();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
this.expandVisualization = () => {
|
||||
$uibModal.open({
|
||||
component: 'widgetDialog',
|
||||
resolve: {
|
||||
widget: this.widget,
|
||||
},
|
||||
size: 'lg',
|
||||
});
|
||||
};
|
||||
|
||||
this.hasParameters = () => this.widget.query.getParametersDefs().length > 0;
|
||||
|
||||
this.editParameterMappings = () => {
|
||||
EditParameterMappingsDialog.showModal({
|
||||
dashboard: this.dashboard,
|
||||
widget: this.widget,
|
||||
}).result.then((valuesChanged) => {
|
||||
this.localParameters = null;
|
||||
|
||||
// refresh widget if any parameter value has been updated
|
||||
if (valuesChanged) {
|
||||
$timeout(() => this.refresh());
|
||||
}
|
||||
$scope.$applyAsync();
|
||||
$rootScope.$broadcast('dashboard.update-parameters');
|
||||
});
|
||||
};
|
||||
|
||||
this.localParametersDefs = () => {
|
||||
if (!this.localParameters) {
|
||||
this.localParameters = filter(
|
||||
this.widget.getParametersDefs(),
|
||||
param => !this.widget.isStaticParam(param),
|
||||
);
|
||||
}
|
||||
return this.localParameters;
|
||||
};
|
||||
|
||||
this.deleteWidget = () => {
|
||||
if (!$window.confirm(`Are you sure you want to remove "${this.widget.getName()}" from the dashboard?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.widget.delete().then(() => {
|
||||
if (this.deleted) {
|
||||
this.deleted({});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Events.record('view', 'widget', this.widget.id);
|
||||
|
||||
this.load = (refresh = false) => {
|
||||
const maxAge = $location.search().maxAge;
|
||||
return this.widget.load(refresh, maxAge);
|
||||
};
|
||||
|
||||
this.refresh = (buttonId) => {
|
||||
this.refreshClickButtonId = buttonId;
|
||||
this.load(true).finally(() => {
|
||||
this.refreshClickButtonId = undefined;
|
||||
});
|
||||
};
|
||||
|
||||
if (this.widget.visualization) {
|
||||
Events.record('view', 'query', this.widget.visualization.query.id, { dashboard: true });
|
||||
Events.record('view', 'visualization', this.widget.visualization.id, { dashboard: true });
|
||||
|
||||
this.type = 'visualization';
|
||||
this.load();
|
||||
} else if (this.widget.restricted) {
|
||||
this.type = 'restricted';
|
||||
} else {
|
||||
this.type = 'textbox';
|
||||
}
|
||||
}
|
||||
|
||||
const DashboardWidgetOptions = {
|
||||
template,
|
||||
controller: DashboardWidgetCtrl,
|
||||
bindings: {
|
||||
widget: '<',
|
||||
public: '<',
|
||||
dashboard: '<',
|
||||
filters: '<',
|
||||
deleted: '<',
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('widgetDialog', WidgetDialog);
|
||||
ngModule.component('dashboardWidget', DashboardWidgetOptions);
|
||||
ngModule.run(['$injector', ($injector) => {
|
||||
DashboardWidget = angular2react('dashboardWidget ', DashboardWidgetOptions, $injector);
|
||||
}]);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -1,121 +0,0 @@
|
||||
.tile .t-header .th-title a.query-link {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
visualization-name:empty + span {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
visualization-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
|
||||
&:after {
|
||||
content: "−";
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&:empty:after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
.th-title p.hidden-print {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.widget-wrapper {
|
||||
.body-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.body-row {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.body-row-auto {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
position: relative;
|
||||
|
||||
.spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
padding: 0;
|
||||
|
||||
.actions {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.t-header.widget {
|
||||
.dropdown {
|
||||
margin-top: -15px;
|
||||
margin-right: -15px;
|
||||
|
||||
.actions {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbox:empty {
|
||||
padding: 0 !important;
|
||||
font-size: 1px !important;
|
||||
}
|
||||
|
||||
.widget-text {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// react-grid-layout overrides
|
||||
.react-grid-item {
|
||||
|
||||
// placeholder color
|
||||
&.react-grid-placeholder {
|
||||
border-radius: 3px;
|
||||
background-color: #E0E6EB;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// resize placeholder behind widget, the lib's default is above 🤷♂️
|
||||
&.resizing {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
// auto-height animation
|
||||
&.cssTransforms:not(.resizing) {
|
||||
transition-property: transform, height; // added ", height"
|
||||
}
|
||||
|
||||
// resize handle size
|
||||
& > .react-resizable-handle::after {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
import Form from 'antd/lib/form';
|
||||
import Input from 'antd/lib/input';
|
||||
import InputNumber from 'antd/lib/input-number';
|
||||
@@ -7,13 +8,17 @@ import Checkbox from 'antd/lib/checkbox';
|
||||
import Button from 'antd/lib/button';
|
||||
import Upload from 'antd/lib/upload';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import { includes, isFunction } from 'lodash';
|
||||
import { includes, isFunction, filter, difference, isEmpty, some, isNumber, isBoolean } from 'lodash';
|
||||
import Select from 'antd/lib/select';
|
||||
import notification from '@/services/notification';
|
||||
import Collapse from '@/components/Collapse';
|
||||
import AceEditorInput from '@/components/AceEditorInput';
|
||||
import { toHuman } from '@/filters';
|
||||
import { Field, Action, AntdForm } from '../proptypes';
|
||||
import helper from './dynamicFormHelper';
|
||||
|
||||
import './DynamicForm.less';
|
||||
|
||||
const fieldRules = ({ type, required, minLength }) => {
|
||||
const requiredRule = required;
|
||||
const minLengthRule = minLength && includes(['text', 'email', 'password'], type);
|
||||
@@ -51,9 +56,14 @@ class DynamicForm extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const hasFilledExtraField = some(props.fields, (field) => {
|
||||
const { extra, initialValue } = field;
|
||||
return extra && (!isEmpty(initialValue) || isNumber(initialValue) || isBoolean(initialValue) && initialValue);
|
||||
});
|
||||
this.state = {
|
||||
isSubmitting: false,
|
||||
inProgressActions: [],
|
||||
showExtraFields: hasFilledExtraField,
|
||||
};
|
||||
|
||||
this.actionCallbacks = this.props.actions.reduce((acc, cur) => ({
|
||||
@@ -146,9 +156,21 @@ class DynamicForm extends React.Component {
|
||||
};
|
||||
|
||||
return getFieldDecorator(name, decoratorOptions)(
|
||||
<Select {...props} optionFilterProp="children" loading={loading || false} mode={mode}>
|
||||
{options && options.map(({ value, title }) => (
|
||||
<Option key={`${value}`} value={value} disabled={readOnly}>{ title || value }</Option>
|
||||
<Select
|
||||
{...props}
|
||||
optionFilterProp="children"
|
||||
loading={loading || false}
|
||||
mode={mode}
|
||||
getPopupContainer={trigger => trigger.parentNode}
|
||||
>
|
||||
{options && options.map(option => (
|
||||
<Option
|
||||
key={`${option.value}`}
|
||||
value={option.value}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{option.name || option.value}
|
||||
</Option>
|
||||
))}
|
||||
</Select>,
|
||||
);
|
||||
@@ -157,7 +179,7 @@ class DynamicForm extends React.Component {
|
||||
renderField(field, props) {
|
||||
const { getFieldDecorator } = this.props.form;
|
||||
const { name, type, initialValue } = field;
|
||||
const fieldLabel = field.title || helper.toHuman(name);
|
||||
const fieldLabel = field.title || toHuman(name);
|
||||
|
||||
const options = {
|
||||
rules: fieldRules(field),
|
||||
@@ -183,11 +205,11 @@ class DynamicForm extends React.Component {
|
||||
return getFieldDecorator(name, options)(<Input {...props} />);
|
||||
}
|
||||
|
||||
renderFields() {
|
||||
return this.props.fields.map((field) => {
|
||||
renderFields(fields) {
|
||||
return fields.map((field) => {
|
||||
const FormItem = Form.Item;
|
||||
const { name, title, type, readOnly, autoFocus, contentAfter } = field;
|
||||
const fieldLabel = title || helper.toHuman(name);
|
||||
const fieldLabel = title || toHuman(name);
|
||||
const { feedbackIcons, form } = this.props;
|
||||
|
||||
const formItemProps = {
|
||||
@@ -239,16 +261,35 @@ class DynamicForm extends React.Component {
|
||||
const submitProps = {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
className: 'w-100',
|
||||
className: 'w-100 m-t-20',
|
||||
disabled: this.state.isSubmitting,
|
||||
loading: this.state.isSubmitting,
|
||||
};
|
||||
const { id, hideSubmitButton, saveText } = this.props;
|
||||
const { id, hideSubmitButton, saveText, fields } = this.props;
|
||||
const { showExtraFields } = this.state;
|
||||
const saveButton = !hideSubmitButton;
|
||||
const extraFields = filter(fields, { extra: true });
|
||||
const regularFields = difference(fields, extraFields);
|
||||
|
||||
return (
|
||||
<Form id={id} layout="vertical" onSubmit={this.handleSubmit}>
|
||||
{this.renderFields()}
|
||||
<Form id={id} className="dynamic-form" layout="vertical" onSubmit={this.handleSubmit}>
|
||||
{this.renderFields(regularFields)}
|
||||
{!isEmpty(extraFields) && (
|
||||
<div className="extra-options">
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
className="extra-options-button"
|
||||
onClick={() => this.setState({ showExtraFields: !showExtraFields })}
|
||||
>
|
||||
Additional Settings
|
||||
<i className={cx('fa m-l-5', { 'fa-caret-up': showExtraFields, 'fa-caret-down': !showExtraFields })} />
|
||||
</Button>
|
||||
<Collapse collapsed={!showExtraFields} className="extra-options-content">
|
||||
{this.renderFields(extraFields)}
|
||||
</Collapse>
|
||||
</div>
|
||||
)}
|
||||
{saveButton && <Button {...submitProps}>{saveText}</Button>}
|
||||
{this.renderActions()}
|
||||
</Form>
|
||||
|
||||
29
client/app/components/dynamic-form/DynamicForm.less
Normal file
29
client/app/components/dynamic-form/DynamicForm.less
Normal file
@@ -0,0 +1,29 @@
|
||||
@import '~@/assets/less/ant';
|
||||
|
||||
.dynamic-form{
|
||||
.extra-options {
|
||||
margin: 25px 0 10px;
|
||||
}
|
||||
|
||||
.extra-options-button {
|
||||
&, &:focus, &:hover {
|
||||
height: 40px;
|
||||
font-weight: 500;
|
||||
background-color: @btn-danger-bg;
|
||||
border-color: @btn-danger-border;
|
||||
color: @btn-default-color;
|
||||
}
|
||||
|
||||
&:focus, &:hover {
|
||||
background-color: fade(@btn-danger-bg, 15%);
|
||||
}
|
||||
}
|
||||
|
||||
.extra-options-content {
|
||||
margin-top: 15px;
|
||||
|
||||
.ant-form-item:last-of-type {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { each, includes, isUndefined } from 'lodash';
|
||||
import { each, includes, isUndefined, isEmpty, map } from 'lodash';
|
||||
|
||||
function orderedInputs(properties, order, targetOptions) {
|
||||
const inputs = new Array(order.length);
|
||||
@@ -11,9 +11,15 @@ function orderedInputs(properties, order, targetOptions) {
|
||||
type: properties[key].type,
|
||||
placeholder: properties[key].default && properties[key].default.toString(),
|
||||
required: properties[key].required,
|
||||
extra: properties[key].extra,
|
||||
initialValue: targetOptions[key],
|
||||
};
|
||||
|
||||
if (input.type === 'select') {
|
||||
input.placeholder = 'Select an option';
|
||||
input.options = properties[key].options;
|
||||
}
|
||||
|
||||
if (position > -1) {
|
||||
inputs[position] = input;
|
||||
} else {
|
||||
@@ -41,27 +47,46 @@ function normalizeSchema(configurationSchema) {
|
||||
prop.type = 'text';
|
||||
}
|
||||
|
||||
if (!isEmpty(prop.enum)) {
|
||||
prop.type = 'select';
|
||||
prop.options = map(prop.enum, value => ({ value, name: value }));
|
||||
}
|
||||
|
||||
if (!isEmpty(prop.extendedEnum)) {
|
||||
prop.type = 'select';
|
||||
prop.options = prop.extendedEnum;
|
||||
}
|
||||
|
||||
prop.required = includes(configurationSchema.required, name);
|
||||
prop.extra = includes(configurationSchema.extra_options, name);
|
||||
});
|
||||
|
||||
configurationSchema.order = configurationSchema.order || [];
|
||||
}
|
||||
|
||||
function setDefaultValueForCheckboxes(configurationSchema, options = {}) {
|
||||
if (Object.keys(options).length === 0) {
|
||||
const properties = configurationSchema.properties;
|
||||
Object.keys(properties).forEach((property) => {
|
||||
if (!isUndefined(properties[property].default) && properties[property].type === 'checkbox') {
|
||||
options[property] = properties[property].default;
|
||||
}
|
||||
});
|
||||
}
|
||||
function setDefaultValueToFields(configurationSchema, options = {}) {
|
||||
const properties = configurationSchema.properties;
|
||||
Object.keys(properties).forEach((key) => {
|
||||
const property = properties[key];
|
||||
// set default value for checkboxes
|
||||
if (!isUndefined(property.default) && property.type === 'checkbox') {
|
||||
options[key] = property.default;
|
||||
}
|
||||
// set default or first value when value has predefined options
|
||||
if (property.type === 'select') {
|
||||
const optionValues = map(property.options, option => option.value);
|
||||
options[key] = includes(optionValues, property.default) ? property.default : optionValues[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getFields(type = {}, target = { options: {} }) {
|
||||
const configurationSchema = type.configuration_schema;
|
||||
normalizeSchema(configurationSchema);
|
||||
setDefaultValueForCheckboxes(configurationSchema, target.options);
|
||||
const hasTargetObject = Object.keys(target.options).length > 0;
|
||||
if (!hasTargetObject) {
|
||||
setDefaultValueToFields(configurationSchema, target.options);
|
||||
}
|
||||
|
||||
const isNewTarget = !target.id;
|
||||
const inputs = [
|
||||
@@ -90,10 +115,6 @@ function updateTargetWithValues(target, values) {
|
||||
});
|
||||
}
|
||||
|
||||
function toHuman(text) {
|
||||
return text.replace(/_/g, ' ').replace(/(?:^|\s)\S/g, a => a.toUpperCase());
|
||||
}
|
||||
|
||||
function getBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -106,6 +127,5 @@ function getBase64(file) {
|
||||
export default {
|
||||
getFields,
|
||||
updateTargetWithValues,
|
||||
toHuman,
|
||||
getBase64,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import { includes } from 'lodash';
|
||||
import { isDynamicDate, getDynamicDate } from '@/services/query';
|
||||
import { isDynamicDate, getDynamicDateFromString } from '@/services/parameters/DateParameter';
|
||||
import DateInput from '@/components/DateInput';
|
||||
import DateTimeInput from '@/components/DateTimeInput';
|
||||
import DynamicButton from '@/components/dynamic-parameters/DynamicButton';
|
||||
@@ -12,11 +12,11 @@ import './DynamicParameters.less';
|
||||
|
||||
const DYNAMIC_DATE_OPTIONS = [
|
||||
{ name: 'Today/Now',
|
||||
value: 'd_now',
|
||||
label: () => getDynamicDate('d_now').value().format('MMM D') },
|
||||
value: getDynamicDateFromString('d_now'),
|
||||
label: () => getDynamicDateFromString('d_now').value().format('MMM D') },
|
||||
{ name: 'Yesterday',
|
||||
value: 'd_yesterday',
|
||||
label: () => getDynamicDate('d_yesterday').value().format('MMM D') },
|
||||
value: getDynamicDateFromString('d_yesterday'),
|
||||
label: () => getDynamicDateFromString('d_yesterday').value().format('MMM D') },
|
||||
];
|
||||
|
||||
class DateParameter extends React.Component {
|
||||
@@ -44,7 +44,7 @@ class DateParameter extends React.Component {
|
||||
onDynamicValueSelect = (dynamicValue) => {
|
||||
const { onSelect, parameter } = this.props;
|
||||
if (dynamicValue === 'static') {
|
||||
const parameterValue = parameter.getValue();
|
||||
const parameterValue = parameter.getExecutionValue();
|
||||
if (parameterValue) {
|
||||
onSelect(moment(parameterValue));
|
||||
} else {
|
||||
@@ -77,7 +77,7 @@ class DateParameter extends React.Component {
|
||||
}
|
||||
|
||||
if (hasDynamicValue) {
|
||||
const dynamicDate = getDynamicDate(value);
|
||||
const dynamicDate = value;
|
||||
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
|
||||
additionalAttributes.value = null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import { includes, isArray, isObject } from 'lodash';
|
||||
import { isDynamicDateRange, getDynamicDateRange } from '@/services/query';
|
||||
import { isDynamicDateRange, getDynamicDateRangeFromString } from '@/services/parameters/DateRangeParameter';
|
||||
import DateRangeInput from '@/components/DateRangeInput';
|
||||
import DateTimeRangeInput from '@/components/DateTimeRangeInput';
|
||||
import DynamicButton from '@/components/dynamic-parameters/DynamicButton';
|
||||
@@ -12,29 +12,37 @@ import './DynamicParameters.less';
|
||||
|
||||
const DYNAMIC_DATE_OPTIONS = [
|
||||
{ name: 'This week',
|
||||
value: 'd_this_week',
|
||||
label: () => getDynamicDateRange('d_this_week').value()[0].format('MMM D') + ' - ' +
|
||||
getDynamicDateRange('d_this_week').value()[1].format('MMM D') },
|
||||
{ name: 'This month', value: 'd_this_month', label: () => getDynamicDateRange('d_this_month').value()[0].format('MMMM') },
|
||||
{ name: 'This year', value: 'd_this_year', label: () => getDynamicDateRange('d_this_year').value()[0].format('YYYY') },
|
||||
value: getDynamicDateRangeFromString('d_this_week'),
|
||||
label: () => getDynamicDateRangeFromString('d_this_week').value()[0].format('MMM D') + ' - ' +
|
||||
getDynamicDateRangeFromString('d_this_week').value()[1].format('MMM D') },
|
||||
{ name: 'This month',
|
||||
value: getDynamicDateRangeFromString('d_this_month'),
|
||||
label: () => getDynamicDateRangeFromString('d_this_month').value()[0].format('MMMM') },
|
||||
{ name: 'This year',
|
||||
value: getDynamicDateRangeFromString('d_this_year'),
|
||||
label: () => getDynamicDateRangeFromString('d_this_year').value()[0].format('YYYY') },
|
||||
{ name: 'Last week',
|
||||
value: 'd_last_week',
|
||||
label: () => getDynamicDateRange('d_last_week').value()[0].format('MMM D') + ' - ' +
|
||||
getDynamicDateRange('d_last_week').value()[1].format('MMM D') },
|
||||
{ name: 'Last month', value: 'd_last_month', label: () => getDynamicDateRange('d_last_month').value()[0].format('MMMM') },
|
||||
{ name: 'Last year', value: 'd_last_year', label: () => getDynamicDateRange('d_last_year').value()[0].format('YYYY') },
|
||||
value: getDynamicDateRangeFromString('d_last_week'),
|
||||
label: () => getDynamicDateRangeFromString('d_last_week').value()[0].format('MMM D') + ' - ' +
|
||||
getDynamicDateRangeFromString('d_last_week').value()[1].format('MMM D') },
|
||||
{ name: 'Last month',
|
||||
value: getDynamicDateRangeFromString('d_last_month'),
|
||||
label: () => getDynamicDateRangeFromString('d_last_month').value()[0].format('MMMM') },
|
||||
{ name: 'Last year',
|
||||
value: getDynamicDateRangeFromString('d_last_year'),
|
||||
label: () => getDynamicDateRangeFromString('d_last_year').value()[0].format('YYYY') },
|
||||
{ name: 'Last 7 days',
|
||||
value: 'd_last_7_days',
|
||||
label: () => getDynamicDateRange('d_last_7_days').value()[0].format('MMM D') + ' - Today' },
|
||||
value: getDynamicDateRangeFromString('d_last_7_days'),
|
||||
label: () => getDynamicDateRangeFromString('d_last_7_days').value()[0].format('MMM D') + ' - Today' },
|
||||
];
|
||||
|
||||
const DYNAMIC_DATETIME_OPTIONS = [
|
||||
{ name: 'Today',
|
||||
value: 'd_today',
|
||||
label: () => getDynamicDateRange('d_today').value()[0].format('MMM D') },
|
||||
value: getDynamicDateRangeFromString('d_today'),
|
||||
label: () => getDynamicDateRangeFromString('d_today').value()[0].format('MMM D') },
|
||||
{ name: 'Yesterday',
|
||||
value: 'd_yesterday',
|
||||
label: () => getDynamicDateRange('d_yesterday').value()[0].format('MMM D') },
|
||||
value: getDynamicDateRangeFromString('d_yesterday'),
|
||||
label: () => getDynamicDateRangeFromString('d_yesterday').value()[0].format('MMM D') },
|
||||
...DYNAMIC_DATE_OPTIONS,
|
||||
];
|
||||
|
||||
@@ -73,7 +81,7 @@ class DateRangeParameter extends React.Component {
|
||||
onDynamicValueSelect = (dynamicValue) => {
|
||||
const { onSelect, parameter } = this.props;
|
||||
if (dynamicValue === 'static') {
|
||||
const parameterValue = parameter.getValue();
|
||||
const parameterValue = parameter.getExecutionValue();
|
||||
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
|
||||
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
|
||||
} else {
|
||||
@@ -107,8 +115,7 @@ class DateRangeParameter extends React.Component {
|
||||
}
|
||||
|
||||
if (hasDynamicValue) {
|
||||
const dynamicDateRange = getDynamicDateRange(value);
|
||||
additionalAttributes.placeholder = [dynamicDateRange && dynamicDateRange.name];
|
||||
additionalAttributes.placeholder = [value && value.name];
|
||||
additionalAttributes.value = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import Dropdown from 'antd/lib/dropdown';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import Menu from 'antd/lib/menu';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import { DynamicDateType } from '@/services/parameters/DateParameter';
|
||||
import { DynamicDateRangeType } from '@/services/parameters/DateRangeParameter';
|
||||
|
||||
import './DynamicButton.less';
|
||||
|
||||
@@ -62,7 +64,7 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
||||
|
||||
DynamicButton.propTypes = {
|
||||
options: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
|
||||
selectedDynamicValue: PropTypes.string,
|
||||
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
|
||||
onSelect: PropTypes.func,
|
||||
enabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ Step.defaultProps = {
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
header,
|
||||
description,
|
||||
illustration,
|
||||
helpLink,
|
||||
@@ -75,7 +75,7 @@ export function EmptyState({
|
||||
return (
|
||||
<div className="empty-state bg-white tiled">
|
||||
<div className="empty-state__summary">
|
||||
{title && <h4>{title}</h4>}
|
||||
{header && <h4>{header}</h4>}
|
||||
<h2>
|
||||
<i className={icon} />
|
||||
</h2>
|
||||
@@ -148,7 +148,7 @@ export function EmptyState({
|
||||
|
||||
EmptyState.propTypes = {
|
||||
icon: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
header: PropTypes.string,
|
||||
description: PropTypes.string.isRequired,
|
||||
illustration: PropTypes.string.isRequired,
|
||||
helpLink: PropTypes.string.isRequired,
|
||||
@@ -161,7 +161,7 @@ EmptyState.propTypes = {
|
||||
|
||||
EmptyState.defaultProps = {
|
||||
icon: null,
|
||||
title: null,
|
||||
header: null,
|
||||
|
||||
onboardingMode: false,
|
||||
showAlertStep: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user