mirror of
https://github.com/getredash/redash.git
synced 2025-12-20 01:47:39 -05:00
Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16f551e22 | ||
|
|
e94515d340 | ||
|
|
8de1fa3318 | ||
|
|
6227a1d071 | ||
|
|
13b6bfc55f | ||
|
|
f5802d2dec | ||
|
|
ba0ccebe58 | ||
|
|
c5a65b3321 | ||
|
|
c622a76f3a | ||
|
|
76e0fa6e9c | ||
|
|
f0ba045913 | ||
|
|
7bf4219e58 | ||
|
|
fe477aa855 | ||
|
|
da09de6def | ||
|
|
f252821400 | ||
|
|
2cdc88293d | ||
|
|
d2d78e7676 | ||
|
|
c74ece4dda | ||
|
|
4a74263522 | ||
|
|
4edfd23772 | ||
|
|
c9b3c95464 | ||
|
|
959822cca6 | ||
|
|
4dea1d681f | ||
|
|
49b3dcaff7 | ||
|
|
b59e210d90 | ||
|
|
10b57b6ee2 | ||
|
|
cc21a32369 | ||
|
|
966b59906f | ||
|
|
a8440d32ab | ||
|
|
e7765440fc | ||
|
|
8af099b658 | ||
|
|
7e9db06633 | ||
|
|
194d4e1750 | ||
|
|
0207ba11a3 | ||
|
|
61a80ad8cc | ||
|
|
cbfd994a28 | ||
|
|
21ac9e8a97 | ||
|
|
d53d05cfb9 | ||
|
|
ee85923b14 | ||
|
|
4866be60de | ||
|
|
56d444b1a5 | ||
|
|
6b39437cdb | ||
|
|
b426e4fdc4 | ||
|
|
e5e926bac5 | ||
|
|
24d68008fa | ||
|
|
0e90b89acc | ||
|
|
2c2f241671 | ||
|
|
d49514abe9 | ||
|
|
934a145ced | ||
|
|
f7c70c2b91 | ||
|
|
69ba165565 | ||
|
|
7b5696dc75 | ||
|
|
4698408a08 | ||
|
|
be142d60df | ||
|
|
aceea6516f | ||
|
|
685b53672e | ||
|
|
7dd62ef948 | ||
|
|
7c2acc34c9 | ||
|
|
c5a90876f3 | ||
|
|
8abaf89394 | ||
|
|
aa2bd0042e | ||
|
|
a7b14bfb9a | ||
|
|
4e5f55a4b7 | ||
|
|
76fbe858ba | ||
|
|
cf7aef1e16 | ||
|
|
77625b2a13 | ||
|
|
c4dcf01b3c | ||
|
|
a167c590b6 | ||
|
|
8e23f93433 | ||
|
|
e41d40bbe0 | ||
|
|
6fc4d5b551 | ||
|
|
f0576a3623 | ||
|
|
9eabf89771 | ||
|
|
11cc274c1c | ||
|
|
8ad08a566a | ||
|
|
ef31d0d768 | ||
|
|
4640c33387 | ||
|
|
9b290913a6 | ||
|
|
db89c4f7bc | ||
|
|
eae1fb7d73 | ||
|
|
4f742aeaac | ||
|
|
5ddad862be | ||
|
|
6f811f163a | ||
|
|
7fb33e3ebb | ||
|
|
f165168860 | ||
|
|
86b0608fde | ||
|
|
cd4daf8823 | ||
|
|
78cae474e0 | ||
|
|
c518c7a4bc | ||
|
|
8c2f51d09d | ||
|
|
6f6c68bd79 | ||
|
|
64f274f58e | ||
|
|
dd89bd885f | ||
|
|
b2295197cf | ||
|
|
ea0e411053 | ||
|
|
9bdb3412a5 | ||
|
|
ad4a760545 | ||
|
|
c1f4147807 | ||
|
|
c054ae8be0 | ||
|
|
d1edd3d068 | ||
|
|
4989bfae60 | ||
|
|
f20a020003 | ||
|
|
01da8c158a | ||
|
|
c83e40b047 | ||
|
|
c3cc65a21d | ||
|
|
5929139ab8 | ||
|
|
66794acd1f | ||
|
|
bce0832e48 | ||
|
|
9f006997a0 | ||
|
|
51d8131db5 | ||
|
|
c793b5dd11 | ||
|
|
4e9da3f116 | ||
|
|
15a8eecdde | ||
|
|
a8ff2500be | ||
|
|
7bf84e856c | ||
|
|
5149bf67ca | ||
|
|
93449db325 | ||
|
|
df57d22e81 | ||
|
|
de0a44ee85 | ||
|
|
261062d491 | ||
|
|
1878e8bf90 | ||
|
|
47fc8a942a | ||
|
|
addecbdd8f | ||
|
|
baec5d56f5 | ||
|
|
1f4325ba8d | ||
|
|
5e5b56ed6a | ||
|
|
45a3b72730 | ||
|
|
cc48de0d8f | ||
|
|
300f3f6780 | ||
|
|
2e4a69cba4 | ||
|
|
6748e9a15d | ||
|
|
7ceb68a468 | ||
|
|
3c1d1e3d4e | ||
|
|
92391e3cbc | ||
|
|
17438002d7 | ||
|
|
a00c5a8857 | ||
|
|
a696fa55f3 | ||
|
|
27259b5abe | ||
|
|
9ee393ec75 | ||
|
|
cfafa97218 | ||
|
|
be580b24a5 | ||
|
|
a6960c5f19 | ||
|
|
6dd321beeb | ||
|
|
27c64b42ac | ||
|
|
99bf6d122c | ||
|
|
d617f57f7d | ||
|
|
21a27ee0b1 | ||
|
|
ac293c7f92 | ||
|
|
8e38dcd244 | ||
|
|
2bab144107 | ||
|
|
4e0a251034 | ||
|
|
7a9f4b07e0 | ||
|
|
1630cbb904 | ||
|
|
f8d05dda9f | ||
|
|
2af8b39d21 | ||
|
|
3faed0fdfe | ||
|
|
e45f49b86e | ||
|
|
e33ad3b164 | ||
|
|
6605f62f3a | ||
|
|
ed2ac407ab | ||
|
|
dda75cce24 | ||
|
|
5b780ac460 | ||
|
|
c0e8ef3000 | ||
|
|
a82fd0cabc | ||
|
|
0e3e2eaf38 | ||
|
|
05f6ef0fb6 | ||
|
|
e433efebc4 | ||
|
|
a9588eac79 | ||
|
|
090b570a71 | ||
|
|
60b12e3121 | ||
|
|
3f8c7333be | ||
|
|
be8dec5f04 | ||
|
|
10b3b50f3d | ||
|
|
6f290ddfa1 | ||
|
|
10b62ebe02 | ||
|
|
04453409da | ||
|
|
b27df216f4 | ||
|
|
a0c76d777b | ||
|
|
2e96e2fb98 | ||
|
|
c2e31f040d | ||
|
|
816f4d912f | ||
|
|
9292ae8d3f | ||
|
|
9480d89e4c | ||
|
|
5dff5b929c | ||
|
|
28e9740e2f | ||
|
|
7679df63ba | ||
|
|
07c9530984 | ||
|
|
aecd0bf37a | ||
|
|
4143bd3f20 | ||
|
|
020dc35faf | ||
|
|
d7b03bac02 | ||
|
|
29875e66d4 | ||
|
|
d97ce15837 | ||
|
|
b263bb7077 | ||
|
|
606cf12e74 | ||
|
|
4508975749 | ||
|
|
c76955be28 | ||
|
|
4f402379e8 | ||
|
|
733b60102d | ||
|
|
b9b30a39d2 | ||
|
|
c74d469181 | ||
|
|
95f11e6686 | ||
|
|
ad6f7109de | ||
|
|
b09ae46a9f | ||
|
|
0cda0369f0 | ||
|
|
50f11069ce | ||
|
|
6bf764be07 | ||
|
|
3159410694 | ||
|
|
76bd2e3c50 | ||
|
|
50a6f723b1 | ||
|
|
0ee20797c8 | ||
|
|
d7515562a4 | ||
|
|
feafbbe318 |
@@ -61,6 +61,7 @@ jobs:
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: sudo pip install -r requirements_bundles.txt
|
||||
- run: npm install
|
||||
- run: npm run bundle
|
||||
- run: npm test
|
||||
@@ -89,27 +90,16 @@ jobs:
|
||||
- run:
|
||||
name: Execute Cypress tests
|
||||
command: npm run cypress run-ci
|
||||
build-tarball:
|
||||
build-docker-image:
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: npm install
|
||||
- run: .circleci/update_version
|
||||
- run: npm run bundle
|
||||
- run: npm run build
|
||||
- run: rm -rf ./node_modules/
|
||||
- run: .circleci/pack
|
||||
- store_artifacts:
|
||||
path: /tmp/artifacts/
|
||||
build-docker-image:
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:xenial
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- checkout
|
||||
- run: sudo apt install python-pip
|
||||
- run: sudo pip install -r requirements_bundles.txt
|
||||
- run: .circleci/update_version
|
||||
- run: npm run bundle
|
||||
- run: .circleci/docker_build
|
||||
workflows:
|
||||
version: 2
|
||||
@@ -125,17 +115,8 @@ workflows:
|
||||
- frontend-e2e-tests:
|
||||
requires:
|
||||
- frontend-lint
|
||||
- build-tarball:
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
- frontend-e2e-tests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- /release\/.*/
|
||||
- build-docker-image:
|
||||
- hold:
|
||||
type: approval
|
||||
requires:
|
||||
- backend-unit-tests
|
||||
- frontend-unit-tests
|
||||
@@ -146,3 +127,6 @@ workflows:
|
||||
- master
|
||||
- preview-image
|
||||
- /release\/.*/
|
||||
- build-docker-image:
|
||||
requires:
|
||||
- hold
|
||||
|
||||
@@ -39,6 +39,10 @@ services:
|
||||
PERCY_BRANCH: ${CIRCLE_BRANCH}
|
||||
PERCY_COMMIT: ${CIRCLE_SHA1}
|
||||
PERCY_PULL_REQUEST: ${CIRCLE_PR_NUMBER}
|
||||
COMMIT_INFO_BRANCH: ${CIRCLE_BRANCH}
|
||||
COMMIT_INFO_AUTHOR: ${CIRCLE_USERNAME}
|
||||
COMMIT_INFO_SHA: ${CIRCLE_SHA1}
|
||||
COMMIT_INFO_REMOTE: ${CIRCLE_REPOSITORY_URL}
|
||||
CYPRESS_PROJECT_ID: ${CYPRESS_PROJECT_ID}
|
||||
CYPRESS_RECORD_KEY: ${CYPRESS_RECORD_KEY}
|
||||
redis:
|
||||
|
||||
113
CHANGELOG.md
113
CHANGELOG.md
@@ -1,5 +1,118 @@
|
||||
# Change Log
|
||||
|
||||
## v8.0.0-beta.2 - 2019-09-16
|
||||
|
||||
This is an update to the previous beta release, which includes:
|
||||
|
||||
* Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details).
|
||||
* Visualizations:
|
||||
- Allow the user to decide how to handle null values in charts.
|
||||
* Upgrade Sentry-SDK to latest version.
|
||||
* Make horizontal table scroll visible in dashboard widgets without scrolling.
|
||||
* Data Sources:
|
||||
* Add support for Azure Data Explorer (Kusto).
|
||||
* MySQL: fix connections without SSL configuration failing.
|
||||
* Amazon Redshift: option to set query group for adhoc/scheduled queries.
|
||||
* Hive: make error message more friendly.
|
||||
* Qubole: add support to run Quantum queries.
|
||||
* Display data source icon in query editor.
|
||||
* Fix: allow users with view only acces to use the queries in Query Results
|
||||
* Dashboard: when updating parameters refersh only widgets that use those parameters.
|
||||
|
||||
This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz.
|
||||
|
||||
|
||||
## v8.0.0-beta - 2019-08-18
|
||||
|
||||
After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements.
|
||||
|
||||
While this version is already running on the hosted platform to make sure it's stable, we're excited to put this in the hands of our Open Source users.
|
||||
|
||||
Starting from this release we will no longer build a tarball distribution of the codebase and recommend everyone to switch over to using our Docker images. We're planning on dropping Python 2 support towards its EOL this year and switching over to the Docker image will make this transition much simpler.
|
||||
|
||||
This release was made possible by contributions from over 40 people: @aidarbek, @AntonZarutsky, @ariarijp, @arikfr, @combineads, @deecay, @fmy, @gabrieldutra, @guwenqing, @guyco33, @ialeinikov, @Jakdaw, @jezdez, @justinclift, @k-tomoyasu, @katty0324, @koooge, @kravets-levko, @ktmud, @KumanoTanaka, @kyoshidajp, @nason, @oldPadavan, @openjck, @osule, @otsaloma, @ranbena, @rauchy, @rueian, @sekiyama58, @shinsuke-nara, @taminif, @The-Alchemist, @vv-p, @washort, @wudi-ayuan, @ygrishaev, @yoavbls, @yoshiken, @yusukegoto and the support of over 500 organizations who subscribed to our hosted version and by that sponsor the team's work.
|
||||
|
||||
### Parameters
|
||||
|
||||
- Parameter UI improvements:
|
||||
- Support for multi-select in dropdown (and query dropdown) parameters.
|
||||
- Support for dynamic values in date and date-range parameters.
|
||||
- Search dropdown parameter values.
|
||||
- New UX for applying parameter changes in queries and dashboards.
|
||||
- Allow using Safe Parameters in visualization embeds and public dashboards. Safe Parameters are any parameter type except for the a text parameter (dropdowns are safe).
|
||||
|
||||
### Data Sources
|
||||
|
||||
- New Data Sources: Couchbase, Phoenix and Dgraph.
|
||||
- New JSON data source (and deprecated old URL data source).
|
||||
- Snowflake: update connector to latest version.
|
||||
- PostgreSQL: show only accessible tables in schema.
|
||||
- BigQuery:
|
||||
- Correctly handle NaN values.
|
||||
- Treat repeated fields as rrays.
|
||||
- [BigQuery] Fix: in some queries there is no mode field
|
||||
- DynamoDB:
|
||||
- Support for Unicode in queries.
|
||||
- Safe loading of schema.
|
||||
- Rockset: better handling of query errors.
|
||||
- Google Sheets:
|
||||
- Support for Team Drive.
|
||||
- Friendlier error message in case of an API error and more reliable test connection.
|
||||
- MySQL:
|
||||
- Support for calling Stored Procedures and better handling of query cancellation.
|
||||
- Switch to using `mysqlclient` (a maintained fork of `Python-MySQL`).
|
||||
- MongoDB: Support serializing Decimal128 values.
|
||||
- Presto: support for passwords in connection settings.
|
||||
- Amazon Athena: allow to specify custom work group.
|
||||
- Query Results: querying a column with a dictionary or array fails
|
||||
- Clickhouse: make sure we don't show password in error messages.
|
||||
- Enable Cassandra support by default.
|
||||
|
||||
### Visualizations
|
||||
|
||||
- Charts:
|
||||
- Fix: legend overlapping chart on small screens.
|
||||
- Fix: Pie chart not rendering when series doesn't exist in options.
|
||||
- Pie Chart: add option to set direction of slices.
|
||||
- WordCloud: rewritten to support new options (provide frequency in query, limits), scale when resizing, handle long words and more.
|
||||
- Pivot Table: support hiding totals.
|
||||
- Counters: apply formatting to target value.
|
||||
- Maps:
|
||||
- Ability to customize marker icon and color.
|
||||
- Customization options for Choropleth maps.
|
||||
- New Visualization: Details View.
|
||||
|
||||
### **UX**
|
||||
|
||||
- Replace blank screen with a loading indicator when the application is doing its first load.
|
||||
- Multiple improvements to dashboards editing: auto-save, grid markings and better refresh indicator.
|
||||
- Admin can now edit user's groups from the user page.
|
||||
- Add keyboard shortcut (Ctrl/Cmd+Shift+F) to trigger query formatting.
|
||||
|
||||
### API
|
||||
|
||||
- Query Result API response minimized to only required fields when called with a non user API key.
|
||||
- Prefer API key over cookies in authentication.
|
||||
- User can now regenerate Query API Key.
|
||||
|
||||
### Other Changes
|
||||
|
||||
- Sends CSP headers to prevent various kinds of security attacks via the browser. Might break unusual usages and embeds of Redash.
|
||||
- New Failed Scheduled Queries email report (can be enabled from organization settings screen).
|
||||
- Deprecated HipChat Alert Destination.
|
||||
- Add options to hide different parts of a Visualization embed UI (parameters, title, link to query).
|
||||
- Support multi-byte search for query names and descriptions (needs to be enabled in Organization settings screen).
|
||||
- CSV query results download: correctly serialize booleans and date values.
|
||||
- Dashboard filters now collect values from all widgets with the same filter.
|
||||
- Support for custom message and description in alert notifications (currently disabled behind a feature flag until we improve the alert UX).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: adding widget to dashboard from a query page is broken.
|
||||
- Fix: default time format option was wrong.
|
||||
- Fix: when too many errors of a scheduled queries occur it causes an OverflowError.
|
||||
- Fix: when forking a query maintain the same visualizations order.
|
||||
|
||||
## v7.0.0 - 2019-03-17
|
||||
|
||||
We're trying a new format for the CHANGELOG in this release. Focusing on the bigger changes, but for whoever interested, you can see all the changes [here](https://github.com/getredash/redash/compare/v6.0.0...master).
|
||||
|
||||
@@ -4,17 +4,18 @@ WORKDIR /frontend
|
||||
COPY package.json package-lock.json /frontend/
|
||||
RUN npm install
|
||||
|
||||
COPY . /frontend
|
||||
COPY client /frontend/client
|
||||
COPY webpack.config.js /frontend/
|
||||
RUN npm run build
|
||||
|
||||
FROM redash/base:latest
|
||||
FROM redash/base:debian
|
||||
|
||||
# Controls whether to install extra dependencies needed for all data sources.
|
||||
ARG skip_ds_deps
|
||||
|
||||
# We first copy only the requirements file, to avoid rebuilding on every file
|
||||
# change.
|
||||
COPY requirements.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
RUN pip install -r requirements.txt -r requirements_dev.txt
|
||||
RUN if [ "x$skip_ds_deps" = "x" ] ; then pip install -r requirements_all_ds.txt ; else echo "Skipping pip install -r requirements_all_ds.txt" ; fi
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<p align="center">
|
||||
<img title="Redash" src='https://redash.io/assets/images/logo.png' width="200px"/>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img title="Build Status" src='https://circleci.com/gh/getredash/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040'/>
|
||||
</p>
|
||||
|
||||
[](https://redash.io/help/)
|
||||
[](https://datree.io/?src=badge)
|
||||
[](https://circleci.com/gh/getredash/redash/tree/master)
|
||||
|
||||
**_Redash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
|
||||
|
||||
|
||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please email security@redash.io to report any security vulnerabilities. We will acknowledge receipt of your vulnerability and strive to send you regular updates about our progress. If you're curious about the status of your disclosure please feel free to email us again. If you want to encrypt your disclosure email, you can use [this PGP key](https://keybase.io/arikfr/key.asc).
|
||||
@@ -1,39 +1,118 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Copy bundle extension files to the client/app/extension directory"""
|
||||
import logging
|
||||
import os
|
||||
from subprocess import call
|
||||
from distutils.dir_util import copy_tree
|
||||
from pathlib2 import Path
|
||||
from shutil import copy
|
||||
from collections import OrderedDict as odict
|
||||
|
||||
from pkg_resources import iter_entry_points, resource_filename, resource_isdir
|
||||
from importlib_metadata import entry_points
|
||||
from importlib_resources import contents, is_resource, path
|
||||
|
||||
# Name of the subdirectory
|
||||
BUNDLE_DIRECTORY = "bundle"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Make a directory for extensions and set it as an environment variable
|
||||
# to be picked up by webpack.
|
||||
EXTENSIONS_RELATIVE_PATH = os.path.join('client', 'app', 'extensions')
|
||||
EXTENSIONS_DIRECTORY = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)),
|
||||
EXTENSIONS_RELATIVE_PATH)
|
||||
extensions_relative_path = Path('client', 'app', 'extensions')
|
||||
extensions_directory = Path(__file__).parent.parent / extensions_relative_path
|
||||
|
||||
if not os.path.exists(EXTENSIONS_DIRECTORY):
|
||||
os.makedirs(EXTENSIONS_DIRECTORY)
|
||||
os.environ["EXTENSIONS_DIRECTORY"] = EXTENSIONS_RELATIVE_PATH
|
||||
if not extensions_directory.exists():
|
||||
extensions_directory.mkdir()
|
||||
os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path)
|
||||
|
||||
for entry_point in iter_entry_points('redash.extensions'):
|
||||
# This is where the frontend code for an extension lives
|
||||
# inside of its package.
|
||||
content_folder_relative = os.path.join(
|
||||
entry_point.name, 'bundle')
|
||||
(root_module, _) = os.path.splitext(entry_point.module_name)
|
||||
|
||||
if not resource_isdir(root_module, content_folder_relative):
|
||||
def resource_isdir(module, resource):
|
||||
"""Whether a given resource is a directory in the given module
|
||||
|
||||
https://importlib-resources.readthedocs.io/en/latest/migration.html#pkg-resources-resource-isdir
|
||||
"""
|
||||
try:
|
||||
return resource in contents(module) and not is_resource(module, resource)
|
||||
except (ImportError, TypeError):
|
||||
# module isn't a package, so can't have a subdirectory/-package
|
||||
return False
|
||||
|
||||
|
||||
def entry_point_module(entry_point):
|
||||
"""Returns the dotted module path for the given entry point"""
|
||||
return entry_point.pattern.match(entry_point.value).group("module")
|
||||
|
||||
|
||||
def load_bundles():
|
||||
""""Load bundles as defined in Redash extensions.
|
||||
|
||||
The bundle entry point can be defined as a dotted path to a module
|
||||
or a callable, but it won't be called but just used as a means
|
||||
to find the files under its file system path.
|
||||
|
||||
The name of the directory it looks for files in is "bundle".
|
||||
|
||||
So a Python package with an extension bundle could look like this::
|
||||
|
||||
my_extensions/
|
||||
├── __init__.py
|
||||
└── wide_footer
|
||||
├── __init__.py
|
||||
└── bundle
|
||||
├── extension.js
|
||||
└── styles.css
|
||||
|
||||
and would then need to register the bundle with an entry point
|
||||
under the "redash.bundles" group, e.g. in your setup.py::
|
||||
|
||||
setup(
|
||||
# ...
|
||||
entry_points={
|
||||
"redash.bundles": [
|
||||
"wide_footer = my_extensions.wide_footer",
|
||||
]
|
||||
# ...
|
||||
},
|
||||
# ...
|
||||
)
|
||||
|
||||
"""
|
||||
bundles = odict()
|
||||
for entry_point in entry_points().get("redash.bundles", []):
|
||||
logger.info('Loading Redash bundle "%s".', entry_point.name)
|
||||
module = entry_point_module(entry_point)
|
||||
# Try to get a list of bundle files
|
||||
if not resource_isdir(module, BUNDLE_DIRECTORY):
|
||||
logger.error(
|
||||
'Redash bundle directory "%s" could not be found.', entry_point.name
|
||||
)
|
||||
continue
|
||||
with path(module, BUNDLE_DIRECTORY) as bundle_dir:
|
||||
bundles[entry_point.name] = list(bundle_dir.rglob("*"))
|
||||
|
||||
return bundles
|
||||
|
||||
|
||||
bundles = load_bundles().items()
|
||||
if bundles:
|
||||
print('Number of extension bundles found: {}'.format(len(bundles)))
|
||||
else:
|
||||
print('No extension bundles found.')
|
||||
|
||||
for bundle_name, paths in bundles:
|
||||
# Shortcut in case not paths were found for the bundle
|
||||
if not paths:
|
||||
print('No paths found for bundle "{}".'.format(bundle_name))
|
||||
continue
|
||||
|
||||
content_folder = resource_filename(root_module, content_folder_relative)
|
||||
# The destination for the bundle files with the entry point name as the subdirectory
|
||||
destination = Path(extensions_directory, bundle_name)
|
||||
if not destination.exists():
|
||||
destination.mkdir()
|
||||
|
||||
# This is where we place our extensions folder.
|
||||
destination = os.path.join(
|
||||
EXTENSIONS_DIRECTORY,
|
||||
entry_point.name)
|
||||
|
||||
copy_tree(content_folder, destination)
|
||||
# Copy the bundle directory from the module to its destination.
|
||||
print('Copying "{}" bundle to {}:'.format(bundle_name, destination.resolve()))
|
||||
for src_path in paths:
|
||||
dest_path = destination / src_path.name
|
||||
print(" - {} -> {}".format(src_path, dest_path))
|
||||
copy(str(src_path), str(dest_path))
|
||||
|
||||
@@ -20,8 +20,21 @@ 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_worker() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
|
||||
SCHEDULE_DB=${SCHEDULE_DB:-celerybeat-schedule}
|
||||
|
||||
echo "Starting dev scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
|
||||
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
|
||||
}
|
||||
|
||||
server() {
|
||||
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app
|
||||
# Recycle gunicorn workers every n-th request. See http://docs.gunicorn.org/en/stable/settings.html#max-requests for more details.
|
||||
MAX_REQUESTS=${MAX_REQUESTS:-1000}
|
||||
MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100}
|
||||
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER
|
||||
}
|
||||
|
||||
create_db() {
|
||||
@@ -41,6 +54,7 @@ help() {
|
||||
echo "server -- start Redash server (with gunicorn)"
|
||||
echo "worker -- start Celery worker"
|
||||
echo "scheduler -- start Celery worker with a beat (scheduler) process"
|
||||
echo "dev_worker -- start Celery worker with a beat (scheduler) process which picks up code changes and reloads"
|
||||
echo "celery_healthcheck -- runs a Celery healthcheck. Useful for Docker's HEALTHCHECK mechanism."
|
||||
echo ""
|
||||
echo "shell -- open shell"
|
||||
@@ -75,6 +89,10 @@ case "$1" in
|
||||
shift
|
||||
scheduler
|
||||
;;
|
||||
dev_worker)
|
||||
shift
|
||||
dev_worker
|
||||
;;
|
||||
dev_server)
|
||||
export FLASK_DEBUG=1
|
||||
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -o errexit # fail the build if any task fails
|
||||
|
||||
flake8 --version ; pip --version
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["airbnb", "plugin:compat/recommended"],
|
||||
plugins: ["jest", "compat"],
|
||||
plugins: ["jest", "compat", "no-only-tests"],
|
||||
settings: {
|
||||
"import/resolver": "webpack"
|
||||
},
|
||||
@@ -26,7 +26,7 @@ module.exports = {
|
||||
"consistent-return": "off",
|
||||
"no-control-regex": "off",
|
||||
"no-multiple-empty-lines": "warn",
|
||||
"no-script-url": "off", // some <a> tags should have href="javascript:void(0)"
|
||||
"no-only-tests/no-only-tests": "error",
|
||||
"operator-linebreak": "off",
|
||||
"react/destructuring-assignment": "off",
|
||||
"react/jsx-filename-extension": "off",
|
||||
|
||||
@@ -4,4 +4,7 @@ module.exports = {
|
||||
env: {
|
||||
"jest/globals": true,
|
||||
},
|
||||
rules: {
|
||||
"jest/no-focused-tests": "off",
|
||||
},
|
||||
};
|
||||
BIN
client/app/assets/images/db-logos/azure_kusto.png
Normal file
BIN
client/app/assets/images/db-logos/azure_kusto.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
client/app/assets/images/db-logos/bigquery_gce.png
Normal file
BIN
client/app/assets/images/db-logos/bigquery_gce.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
client/app/assets/images/db-logos/dgraph.png
Normal file
BIN
client/app/assets/images/db-logos/dgraph.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
client/app/assets/images/db-logos/json.png
Normal file
BIN
client/app/assets/images/db-logos/json.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -33,8 +33,27 @@
|
||||
@import "~antd/lib/spin/style/index";
|
||||
@import "~antd/lib/tabs/style/index";
|
||||
@import "~antd/lib/notification/style/index";
|
||||
@import "~antd/lib/collapse/style/index";
|
||||
@import "~antd/lib/progress/style/index";
|
||||
@import "~antd/lib/typography/style/index";
|
||||
@import 'inc/ant-variables';
|
||||
|
||||
// Increase z-indexes to avoid conflicts with some other libraries (e.g. Plotly)
|
||||
@zindex-modal: 2000;
|
||||
@zindex-modal-mask: 2000;
|
||||
@zindex-message: 2010;
|
||||
@zindex-notification: 2010;
|
||||
@zindex-popover: 2030;
|
||||
@zindex-dropdown: 2050;
|
||||
@zindex-picker: 2050;
|
||||
@zindex-tooltip: 2060;
|
||||
|
||||
.@{drawer-prefix-cls} {
|
||||
&.help-drawer {
|
||||
z-index: @zindex-tooltip; // help drawer should be topmost
|
||||
}
|
||||
}
|
||||
|
||||
// Remove bold in labels for Ant checkboxes and radio buttons
|
||||
.ant-checkbox-wrapper,
|
||||
.ant-radio-wrapper {
|
||||
@@ -58,11 +77,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for Ant dropdowns when they are used in Boootstrap modals
|
||||
.ant-dropdown-in-bootstrap-modal {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
// Button overrides
|
||||
.@{btn-prefix-cls} {
|
||||
transition-duration: 150ms;
|
||||
@@ -136,6 +150,10 @@
|
||||
border-color: transparent;
|
||||
color: @pagination-color;
|
||||
line-height: @pagination-item-size - 2px;
|
||||
|
||||
.@{pagination-prefix-cls}.mini & {
|
||||
line-height: @pagination-item-size-sm - 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus .@{pagination-prefix-cls}-item-link,
|
||||
@@ -288,10 +306,6 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ant-popover {
|
||||
z-index: 1000; // make sure it doesn't cover drawer
|
||||
}
|
||||
|
||||
// Notification overrides
|
||||
.@{notification-prefix-cls} {
|
||||
// vertical centering
|
||||
@@ -308,3 +322,44 @@
|
||||
.@{btn-prefix-cls} .@{iconfont-css-prefix}-ellipsis {
|
||||
margin: 0 -7px;
|
||||
}
|
||||
|
||||
// Collapse
|
||||
|
||||
.@{collapse-prefix-cls} {
|
||||
&&-headerless {
|
||||
border: 0;
|
||||
background: none;
|
||||
|
||||
.@{collapse-prefix-cls}-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.@{collapse-prefix-cls}-item,
|
||||
.@{collapse-prefix-cls}-content {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.@{collapse-prefix-cls}-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overrides for tall form components such as ace editor
|
||||
.@{form-prefix-cls}-item {
|
||||
&-children {
|
||||
display: block; // so feeback icon positions correctly
|
||||
}
|
||||
|
||||
// no change for short components, sticks to body for tall ones
|
||||
&-children-icon {
|
||||
top: auto !important;
|
||||
bottom: 8px;
|
||||
|
||||
// makes the icon white instead of see-through
|
||||
& svg {
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
.alert {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
padding: 15px;
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
@font-size-base: 13px;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Borders
|
||||
-----------------------------------------------------------*/
|
||||
@border-color-split: #f0f0f0;
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Typograpgy
|
||||
-----------------------------------------------------------*/
|
||||
|
||||
@@ -19,11 +19,15 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: @header-height;
|
||||
position: relative;
|
||||
&.headless {
|
||||
padding-top: 0;
|
||||
.nav.app-header {
|
||||
background: #F6F8F9;
|
||||
font-family: @redash-font;
|
||||
position: relative;
|
||||
|
||||
&.headless {
|
||||
padding-top: 10px;
|
||||
|
||||
.nav.app-header, .navbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -72,10 +76,34 @@ strong {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed width layout for specific pages
|
||||
@media (min-width: 768px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 750px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 970px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 1170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbox {
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
|
||||
}
|
||||
|
||||
.clickable {
|
||||
@@ -95,3 +123,150 @@ strong {
|
||||
resize: both !important;
|
||||
transition: height 0s, width 0s !important;
|
||||
}
|
||||
|
||||
// Ace Editor
|
||||
.ace_editor {
|
||||
border: 1px solid fade(@redash-gray, 15%) !important;
|
||||
}
|
||||
|
||||
.ace-tm {
|
||||
.ace_gutter {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ace_gutter-active-line {
|
||||
background-color: fade(@redash-gray, 20%) !important;
|
||||
}
|
||||
|
||||
.ace_marker-layer .ace_active-line {
|
||||
background: fade(@redash-gray, 9%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-ace {
|
||||
background-color: fade(@redash-gray, 12%) !important;
|
||||
}
|
||||
|
||||
// resizeable
|
||||
.rg-top span, .rg-bottom span {
|
||||
height: 3px;
|
||||
border-color: #b1c1ce; // TODO: variable
|
||||
}
|
||||
|
||||
.rg-bottom {
|
||||
bottom: 15px;
|
||||
|
||||
span {
|
||||
margin: 1.5px 0 0 -10px;
|
||||
}
|
||||
}
|
||||
|
||||
// Plotly
|
||||
text.slicetext {
|
||||
text-shadow: 1px 1px 5px #333;
|
||||
}
|
||||
|
||||
// markdown
|
||||
.markdown strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.profile__image--navbar {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.profile__image--settings {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.profile__image_thumb {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
// Error state
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
margin-top: 25vh;
|
||||
padding: 35px;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
|
||||
.error-state__icon {
|
||||
.zmdi {
|
||||
font-size: 64px;
|
||||
color: @redash-gray;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
}
|
||||
|
||||
// page
|
||||
.page-header--new .btn-favourite, .page-header--new .btn-archive {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
display: block;
|
||||
|
||||
favorites-control {
|
||||
float: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
width: 100%;
|
||||
margin-bottom: 5px !important;
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-wrapper, .page-header--new {
|
||||
h3 {
|
||||
margin: 0.2em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.select-option-divider {
|
||||
margin: 10px 0 !important;
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
.collapsing,
|
||||
.collapse.in {
|
||||
padding: 5px 10px;
|
||||
padding: 0;
|
||||
transition: all 0.35s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,3 +122,21 @@
|
||||
top: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.btn-default {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
}
|
||||
|
||||
.btn-transparent {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-default:hover, .btn-default:focus, .btn-default.focus, .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default {
|
||||
background-color: fade(@redash-gray, 25%);
|
||||
}
|
||||
|
||||
.btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus {
|
||||
color: #333;
|
||||
background-color: fade(@redash-gray, 45%);
|
||||
}
|
||||
@@ -55,14 +55,17 @@ textarea.v-resizable {
|
||||
.transition-duration(300ms);
|
||||
resize: none;
|
||||
box-shadow: 0 0 0 40px rgba(0, 0, 0, 0) !important;
|
||||
border-radius: 0;
|
||||
border-radius: @redash-input-radius;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 1px -2px rgba(121,194,255,0.5) !important;
|
||||
box-shadow: none !important;
|
||||
border-color: @blue;
|
||||
}
|
||||
&:hover {
|
||||
border-color: @blue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Custom Checkbox + Radio
|
||||
-----------------------------------------------------------*/
|
||||
|
||||
@@ -154,3 +154,9 @@
|
||||
Border Radius
|
||||
-----------------------------------------------------------*/
|
||||
.brd-2 { border-radius: 2px; }
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Alignment
|
||||
-----------------------------------------------------------*/
|
||||
.va-top { vertical-align: top; }
|
||||
@@ -1,14 +1,37 @@
|
||||
.label {
|
||||
border-radius: 1px;
|
||||
padding: 4px 5px 3px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
.label {
|
||||
border-radius: 2px;
|
||||
}
|
||||
padding: 3px 6px 4px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.label-default {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-archived {
|
||||
.label-warning();
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished,
|
||||
.label-tag-archived,
|
||||
.label-tag {
|
||||
margin-right: 3px;
|
||||
display: inline;
|
||||
margin-top: 2px;
|
||||
max-width: 24ch;
|
||||
.text-overflow();
|
||||
}
|
||||
@@ -31,6 +31,17 @@ tags-list {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
.badge-light {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.max-character {
|
||||
.text-overflow();
|
||||
}
|
||||
@@ -45,6 +56,11 @@ tags-list {
|
||||
line-height: 100%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.active, &.active:hover, &.active:focus {
|
||||
background-color: #fff;
|
||||
box-shadow: inset 3px 0px 0px @brand-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item-heading {
|
||||
@@ -76,3 +92,18 @@ tags-list {
|
||||
height: 38px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ui-select-choices-row.disabled > span {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
.list-group-item.inactive,
|
||||
.ui-select-choices-row.disabled {
|
||||
background-color: #eee !important;
|
||||
border-color: transparent;
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
color: #333;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -30,3 +30,266 @@ a.navbar-brand img {
|
||||
left: -9px;
|
||||
bottom: -11px;
|
||||
}
|
||||
|
||||
.caret--nav {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.caret--nav:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 9px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: block;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.296,4.288 L9.382,0.2 C9.66086822,-0.0716916976 10.1065187,-0.068122925 10.381,0.208 C10.661,0.488 10.661,0.932 10.388,1.206 L5.792,5.803 C5.6602899,5.93388911 5.48167943,6.00662966 5.296,6.005 C5.10997499,6.00689786 4.93095449,5.93413702 4.799,5.803 L0.204,1.207 C0.072163111,1.07394937 -0.00121750401,0.893846387 9.62313189e-05,0.706545264 C0.00140996665,0.519244142 0.0773097323,0.340188219 0.211,0.209 C0.485365732,-0.0664648737 0.930253538,-0.0700311086 1.209,0.201 L5.296,4.288 L5.296,4.288 Z' id='Shape' fill='%23000000'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
|
||||
background-size: 100% 100%;
|
||||
transition: transform .2s cubic-bezier(.75,0,.25,1);
|
||||
}
|
||||
|
||||
.navbar .caret--nav:after {
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
.dropdown--profile .caret--nav:after {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.btn--create {
|
||||
padding-right: 20px;
|
||||
|
||||
.caret--nav:after {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.29592111,4.28945339 L9.38192111,0.201453387 C9.66078932,-0.0702383105 10.1064398,-0.0666695379 10.3809211,0.209453387 C10.6609211,0.489453387 10.6609211,0.933453387 10.3879211,1.20745339 L5.79192111,5.80445339 C5.66021101,5.9353425 5.48160054,6.00808305 5.29592111,6.00645339 C5.1098961,6.00835125 4.9308756,5.9355904 4.79892111,5.80445339 L0.203921109,1.20845339 C0.0720842204,1.07540275 -0.00129639464,0.895299774 1.73406884e-05,0.707998651 C0.00133107602,0.520697529 0.0772308417,0.341641606 0.210921109,0.210453387 C0.485286842,-0.0650114866 0.930174648,-0.0685777215 1.20892111,0.202453387 L5.29592111,4.28945339 L5.29592111,4.28945339 Z' id='Shape' fill='%23FCFCFC'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown.open .caret--nav:after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
.navbar-collapse {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
a.dropdown--profile {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
line-height: 2.35;
|
||||
}
|
||||
|
||||
.navbar-inverse {
|
||||
background-color: @redash-gray;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -25px !important; // center
|
||||
display: block;
|
||||
zoom: 0.9;
|
||||
}
|
||||
|
||||
.menu-search {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar .collapse.in {
|
||||
background: #fff;
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
.navbar {
|
||||
min-height: initial;
|
||||
height: 50px;
|
||||
border: 1px solid #fff;
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.btn-group.open .dropdown-toggle {
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-group .btn:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-link-ANGULAR_REMOVE_ME {
|
||||
line-height: 18px;
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-link-ANGULAR_REMOVE_ME,
|
||||
.navbar-default .navbar-nav > li > a {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
|
||||
&:active, &:hover, &:focus {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-default .btn__new button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn__new {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > li > a:hover {
|
||||
//background-color: fade(@redash-gray, 10%);
|
||||
//text-decoration: underline;
|
||||
//border-radius: 0;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a:hover, .navbar-default .navbar-nav > .open > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
|
||||
// Responsive fixes
|
||||
@media (max-width: 767px) {
|
||||
.navbar-brand {
|
||||
left: 2%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
//Fix navbar collapse
|
||||
.navbar .collapse.in {
|
||||
border: none;
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--profile {
|
||||
.caret--nav:after {
|
||||
right: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--profile__username {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav__main li a {
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
text-align: left;
|
||||
float: none !important;
|
||||
}
|
||||
|
||||
.navbar-form {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@media (max-width: 880px) {
|
||||
.navbar-link-ANGULAR_REMOVE_ME,
|
||||
.navbar-default .navbar-nav > li > a,
|
||||
.navbar-form {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
margin-left: -15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 810px) {
|
||||
.menu-search {
|
||||
width: 175px;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
margin-left: 13px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1084px) {
|
||||
.dropdown--profile__username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Cross-browser fixes
|
||||
|
||||
// Firefox
|
||||
@-moz-document url-prefix() {
|
||||
.caret--nav::after {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.navbar .caret--nav::after {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.navbar .btn--create .caret--nav::after {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// IE10+
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.caret--nav::after {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.navbar .caret--nav::after {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.navbar .btn--create .caret--nav::after {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa {
|
||||
font-size: 100%;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.pagination {
|
||||
border-radius: 0;
|
||||
|
||||
& > li {
|
||||
margin: 0 2px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
& > a,
|
||||
& > span {
|
||||
border-radius: 50% !important;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
|
||||
& > .zmdi {
|
||||
font-size: 22px;
|
||||
line-height: 39px;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.opacity(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Listview Pagination
|
||||
-----------------------------------------------------------*/
|
||||
.lv-pagination {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Pager
|
||||
-----------------------------------------------------------*/
|
||||
.pager li > a, .pager li > span {
|
||||
padding: 5px 10px 6px;
|
||||
color: @pagination-color;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.popover {
|
||||
box-shadow: 0 2px 30px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px;
|
||||
}
|
||||
|
||||
.popover-title {
|
||||
|
||||
@@ -132,9 +132,15 @@
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
margin-bottom: 0px;
|
||||
|
||||
> li.rd-tab-btn {
|
||||
float: right;
|
||||
padding-right: 10px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
> li > a {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,3 +97,53 @@
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.table-data {
|
||||
tbody > tr > td {
|
||||
padding-top: 5px !important;
|
||||
}
|
||||
|
||||
.btn-favourite, .btn-archive {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-main-title {
|
||||
font-weight: 500;
|
||||
line-height: 1.7 !important;
|
||||
}
|
||||
|
||||
.btn-favourite {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @yellow-darker;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fa-star {
|
||||
color: @yellow-darker;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-archive {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @gray-light;
|
||||
}
|
||||
|
||||
.fa-archive {
|
||||
color: @gray-light;
|
||||
}
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.table-data .label-tag {
|
||||
display: inline-block;
|
||||
max-width: 135px;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
background-color: #fff;
|
||||
margin-bottom: @grid-gutter-width;
|
||||
position: relative;
|
||||
box-shadow: @tile-shadow;
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
&[class*="bg-"] {
|
||||
color: #fff;
|
||||
@@ -12,6 +13,10 @@
|
||||
margin-bottom: @grid-gutter-width/2;
|
||||
}
|
||||
}
|
||||
.tiled {
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
}
|
||||
|
||||
.t-header {
|
||||
.th-title {
|
||||
@@ -74,6 +79,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.t-header:not(.th-alt) {
|
||||
padding: 15px;
|
||||
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
line-height: 2.2;
|
||||
}
|
||||
}
|
||||
|
||||
.tb-padding {
|
||||
padding: 20px 23px 30px;
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
.pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
.pivot-table-renderer > 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';
|
||||
@@ -54,11 +53,9 @@
|
||||
@import 'inc/schema-browser';
|
||||
@import 'inc/toast';
|
||||
@import 'inc/visualizations/box';
|
||||
@import 'inc/visualizations/counter-render';
|
||||
@import 'inc/visualizations/sankey';
|
||||
@import 'inc/visualizations/pivot-table';
|
||||
@import 'inc/visualizations/map';
|
||||
@import 'inc/visualizations/chart';
|
||||
@import 'inc/visualizations/sunburst';
|
||||
@import 'inc/visualizations/cohort';
|
||||
@import 'inc/visualizations/misc';
|
||||
@@ -71,10 +68,11 @@
|
||||
@import 'inc/vendor-overrides/ui-select';
|
||||
|
||||
/** REDASH STYLING **/
|
||||
@import 'redash/redash-newstyle';
|
||||
@import 'redash/redash-table';
|
||||
@import 'redash/query';
|
||||
@import 'redash/tags-control';
|
||||
@import 'redash/css-logo';
|
||||
@import 'redash/loading-indicator';
|
||||
|
||||
|
||||
|
||||
|
||||
88
client/app/assets/less/redash/css-logo.less
Normal file
88
client/app/assets/less/redash/css-logo.less
Normal file
@@ -0,0 +1,88 @@
|
||||
// based on https://github.com/outbrain/tech-companies-logos-in-css/pull/28
|
||||
|
||||
@primary: #ff7964;
|
||||
@shadow: #ef6c58;
|
||||
@bar: white;
|
||||
|
||||
#css-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
|
||||
#circle {
|
||||
width: 79px;
|
||||
height: 79px;
|
||||
background-color: @shadow;
|
||||
border-radius: 50%;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
& > div {
|
||||
width: 79px;
|
||||
height: 73px;
|
||||
background-color: @primary;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#bars {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 24px;
|
||||
right: 0;
|
||||
height: 33px;
|
||||
display: flex;
|
||||
padding: 0 22px 0;
|
||||
|
||||
.bar {
|
||||
background: @bar;
|
||||
box-shadow: 0px 2px 0 0 @shadow;
|
||||
display: inline-block;
|
||||
border-radius: 1px;
|
||||
align-self: flex-end;
|
||||
flex: 1;
|
||||
margin: 0 2px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:nth-child(1) {
|
||||
height: 32%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
height: 71%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#point,
|
||||
#point > div {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 17px solid @shadow;
|
||||
border-right-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
bottom: 0;
|
||||
left: 48px;
|
||||
transform: scaleX(0.87);
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
#point > div {
|
||||
bottom: -12px;
|
||||
border-color: @primary;
|
||||
transform: scaleX(1.04);
|
||||
left: -17px;
|
||||
}
|
||||
}
|
||||
51
client/app/assets/less/redash/loading-indicator.less
Normal file
51
client/app/assets/less/redash/loading-indicator.less
Normal file
@@ -0,0 +1,51 @@
|
||||
.loading-indicator {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -50px 0 0 -50px; // center
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: linear;
|
||||
transition-property: opacity, transform;
|
||||
|
||||
#css-logo {
|
||||
animation: hover 2s infinite;
|
||||
}
|
||||
|
||||
#shadow {
|
||||
width: 33px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: black;
|
||||
opacity: 0.25;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 34px;
|
||||
top: 115px;
|
||||
animation: shadow 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes hover {
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
@keyframes shadow {
|
||||
50% {
|
||||
transform: scaleX(0.9);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hide indicator when app-view has content
|
||||
app-view:not(:empty) ~ .loading-indicator {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
|
||||
* {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ edit-in-place p.editable:hover {
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.ace_editor.ace_autocomplete .ace_completion-highlight {
|
||||
@@ -208,18 +208,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 +343,8 @@ a.label-tag {
|
||||
border-bottom: 1px solid #efefef;
|
||||
}
|
||||
|
||||
.pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
.pivot-table-renderer > table,
|
||||
visualization-renderer > .visualization-renderer-wrapper {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -676,8 +677,17 @@ nav .rg-bottom {
|
||||
.filter-container {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-edit-visualisation {
|
||||
// Responsive fixes
|
||||
@media (max-width: 767px) {
|
||||
.query-page-wrapper {
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,939 +0,0 @@
|
||||
@import (reference, less) '~bootstrap/less/labels.less';
|
||||
|
||||
// Variables
|
||||
@redash-gray: rgba(102, 136, 153, 1);
|
||||
@redash-orange: rgba(255, 120, 100, 1);
|
||||
@redash-black: rgba(0, 0, 0, 1);
|
||||
@redash-yellow: rgba(252, 252, 161, 0.75);
|
||||
@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
|
||||
@spacing: 15px;
|
||||
|
||||
//Default spacing (between tiles)
|
||||
@redash-space: 10px;
|
||||
|
||||
@redash-radius: 3px;
|
||||
@redash-input-radius: 2px;
|
||||
|
||||
// General
|
||||
body {
|
||||
padding-top: 0;
|
||||
background: #F6F8F9;
|
||||
font-family: @redash-font;
|
||||
|
||||
&.headless {
|
||||
padding-top: 10px;
|
||||
|
||||
.navbar {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.word-wrap-break {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.clearboth {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 20px;
|
||||
border: 1px solid #eee;
|
||||
border-left-width: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
border-left-color: #aa6708;
|
||||
}
|
||||
|
||||
.callout-info {
|
||||
border-left-color: #1b809e;
|
||||
}
|
||||
|
||||
// Fixed width layout for specific pages
|
||||
@media (min-width: 768px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 750px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 970px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
|
||||
.container {
|
||||
width: 1170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.creation-container {
|
||||
h5 {
|
||||
color: #a7a7a7;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-widget-container {
|
||||
background: #fff;
|
||||
border-radius: @redash-radius;
|
||||
padding: 15px;
|
||||
position: fixed;
|
||||
left: 15px;
|
||||
bottom: 20px;
|
||||
width: calc(~'100% - 30px');
|
||||
z-index: 99;
|
||||
box-shadow: fade(@redash-gray, 50%) 0px 7px 29px -3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 2.1;
|
||||
font-weight: 400;
|
||||
|
||||
.zmdi {
|
||||
margin: 0;
|
||||
margin-right: 5px;
|
||||
font-size: 24px;
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
padding-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
.ace-tm .ace_gutter {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ace_editor {
|
||||
border: 1px solid fade(@redash-gray, 15%);
|
||||
}
|
||||
|
||||
.ace-tm .ace_gutter-active-line {
|
||||
background-color: fade(@redash-gray, 20%);
|
||||
}
|
||||
|
||||
.ace-tm .ace_marker-layer .ace_active-line {
|
||||
background: fade(@redash-gray, 9%);
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item.active, .list-group-item.active:hover, .list-group-item.active:focus {
|
||||
background-color: #fff;
|
||||
box-shadow: inset 3px 0px 0px @brand-primary;
|
||||
}
|
||||
|
||||
.table-data {
|
||||
tbody > tr > td {
|
||||
padding-top: 5px !important;
|
||||
}
|
||||
|
||||
.btn-favourite, .btn-archive {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-main-title {
|
||||
font-weight: 500;
|
||||
line-height: 1.7 !important;
|
||||
a {
|
||||
//font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-favourite, .btn-archive {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @yellow-darker;
|
||||
}
|
||||
|
||||
.fa-star {
|
||||
color: @yellow-darker;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-archive {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @gray-light;
|
||||
}
|
||||
|
||||
.fa-archive {
|
||||
color: @gray-light;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header--new .btn-favourite, .page-header--new .btn-archive {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
display: block;
|
||||
|
||||
favorites-control {
|
||||
float: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
width: 100%;
|
||||
margin-bottom: 5px !important;
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin: 5px 0;
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.t-header:not(.th-alt) {
|
||||
padding: 15px;
|
||||
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
line-height: 2.2;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-wrapper, .page-header--new {
|
||||
h3 {
|
||||
margin: 0.2em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.dynamic-table__pagination {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.rg-top span, .rg-bottom span {
|
||||
height: 3px;
|
||||
border-color: #b1c1ce; // TODO: variable
|
||||
}
|
||||
|
||||
.rg-bottom {
|
||||
bottom: 15px;
|
||||
|
||||
span {
|
||||
margin: 1.5px 0 0 -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px;
|
||||
}
|
||||
|
||||
.tile__bottom-control a {
|
||||
color: fade(@redash-black, 65%);
|
||||
|
||||
&:hover {
|
||||
color: fade(@redash-black, 95%);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
.disabled a {
|
||||
background-color: fade(@redash-gray, 14%);
|
||||
}
|
||||
|
||||
li {
|
||||
a {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
|
||||
&:hover {
|
||||
background-color: fade(@redash-gray, 25%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
}
|
||||
|
||||
.btn-transparent {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-default:hover, .btn-default:focus, .btn-default.focus, .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default {
|
||||
background-color: fade(@redash-gray, 25%);
|
||||
}
|
||||
|
||||
.btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus {
|
||||
color: #333;
|
||||
background-color: fade(@redash-gray, 45%);
|
||||
}
|
||||
|
||||
.label {
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px 4px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.label-default {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished {
|
||||
background: fade(@redash-gray, 85%);
|
||||
}
|
||||
|
||||
.label-tag-archived {
|
||||
.label-warning();
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
|
||||
.label-tag-unpublished,
|
||||
.label-tag-archived,
|
||||
.label-tag {
|
||||
margin-right: 3px;
|
||||
display: inline;
|
||||
margin-top: 2px;
|
||||
max-width: 24ch;
|
||||
.text-overflow();
|
||||
}
|
||||
|
||||
.tab-nav > li > a {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
position: -webkit-sticky; // required for Safari
|
||||
position: sticky;
|
||||
background: #f6f7f9;
|
||||
z-index: 99;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.dashboard__control {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.editing-mode {
|
||||
a.query-link {
|
||||
pointer-events: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.th-title {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
position: -webkit-sticky; // required for Safari
|
||||
position: sticky;
|
||||
background: #f6f7f9;
|
||||
z-index: 99;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.widget-wrapper {
|
||||
.parameter-container {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-ace {
|
||||
background-color: fade(@redash-gray, 12%) !important;
|
||||
}
|
||||
|
||||
.tiled {
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
border-radius: 3px;
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
.widget-menu-regular, .btn__refresh {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
}
|
||||
|
||||
.t-header {
|
||||
.th-title {
|
||||
a {
|
||||
color: fade(@redash-black, 80%);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.query--description {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.t-header.widget {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.widget-menu-regular, .btn__refresh {
|
||||
opacity: 1 !important;
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.tile__bottom-control {
|
||||
padding: 10px 15px;
|
||||
line-height: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.embed-heading {
|
||||
h3 {
|
||||
line-height: 1.75;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
// Navigation
|
||||
.caret--nav {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.caret--nav:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 9px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: block;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.296,4.288 L9.382,0.2 C9.66086822,-0.0716916976 10.1065187,-0.068122925 10.381,0.208 C10.661,0.488 10.661,0.932 10.388,1.206 L5.792,5.803 C5.6602899,5.93388911 5.48167943,6.00662966 5.296,6.005 C5.10997499,6.00689786 4.93095449,5.93413702 4.799,5.803 L0.204,1.207 C0.072163111,1.07394937 -0.00121750401,0.893846387 9.62313189e-05,0.706545264 C0.00140996665,0.519244142 0.0773097323,0.340188219 0.211,0.209 C0.485365732,-0.0664648737 0.930253538,-0.0700311086 1.209,0.201 L5.296,4.288 L5.296,4.288 Z' id='Shape' fill='%23000000'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
|
||||
background-size: 100% 100%;
|
||||
transition: transform .2s cubic-bezier(.75,0,.25,1);
|
||||
}
|
||||
|
||||
.navbar .caret--nav:after {
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
.dropdown--profile .caret--nav:after {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.btn--create {
|
||||
padding-right: 20px;
|
||||
|
||||
.caret--nav:after {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='11px' height='6px' viewBox='0 0 11 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3C!-- Generator: Sketch 42 %2836781%29 - http://www.bohemiancoding.com/sketch --%3E%3Ctitle%3EShape%3C/title%3E%3Cdesc%3ECreated with Sketch.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'%3E%3Cpath d='M5.29592111,4.28945339 L9.38192111,0.201453387 C9.66078932,-0.0702383105 10.1064398,-0.0666695379 10.3809211,0.209453387 C10.6609211,0.489453387 10.6609211,0.933453387 10.3879211,1.20745339 L5.79192111,5.80445339 C5.66021101,5.9353425 5.48160054,6.00808305 5.29592111,6.00645339 C5.1098961,6.00835125 4.9308756,5.9355904 4.79892111,5.80445339 L0.203921109,1.20845339 C0.0720842204,1.07540275 -0.00129639464,0.895299774 1.73406884e-05,0.707998651 C0.00133107602,0.520697529 0.0772308417,0.341641606 0.210921109,0.210453387 C0.485286842,-0.0650114866 0.930174648,-0.0685777215 1.20892111,0.202453387 L5.29592111,4.28945339 L5.29592111,4.28945339 Z' id='Shape' fill='%23FCFCFC'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown.open .caret--nav:after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapsing, .collapse.in {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
min-height: initial;
|
||||
height: 50px;
|
||||
border: 1px solid #fff;
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.btn-group.open .dropdown-toggle {
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-group .btn:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-link-ANGULAR_REMOVE_ME {
|
||||
line-height: 18px;
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-link-ANGULAR_REMOVE_ME,
|
||||
.navbar-default .navbar-nav > li > a {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
|
||||
&:active, &:hover, &:focus {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-default .btn__new button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > li > a:hover {
|
||||
//background-color: fade(@redash-gray, 10%);
|
||||
//text-decoration: underline;
|
||||
//border-radius: 0;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > .open > a, .navbar-default .navbar-nav > .open > a:hover, .navbar-default .navbar-nav > .open > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus {
|
||||
background-color: fade(@redash-gray, 15%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.profile__image--navbar {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.profile__image--settings {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.profile__image_thumb {
|
||||
border-radius: 100%;
|
||||
margin-right: 3px;
|
||||
margin-top: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.user_list__user--invitation-pending {
|
||||
color: fade(@alert-danger-bg, 75%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn__new {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.navbar-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -25px !important; // center
|
||||
display: block;
|
||||
zoom: 0.9;
|
||||
}
|
||||
|
||||
.va-top {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
|
||||
|
||||
.navbar-collapse {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
a.dropdown--profile {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
line-height: 2.35;
|
||||
}
|
||||
|
||||
.navbar-inverse {
|
||||
background-color: @redash-gray;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-search {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
|
||||
.badge-light {
|
||||
background: fade(@redash-gray, 10%);
|
||||
color: fade(@redash-gray, 75%);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar .collapse.in {
|
||||
background: #fff;
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination > li > a, .pagination > li > span {
|
||||
border-radius: 3px !important;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
line-height: 31px;
|
||||
}
|
||||
|
||||
// Error state
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
margin-top: 25vh;
|
||||
padding: 35px;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
|
||||
.error-state__icon {
|
||||
.zmdi {
|
||||
font-size: 64px;
|
||||
color: @redash-gray;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
}
|
||||
|
||||
// Forms
|
||||
.form-control {
|
||||
border-radius: @redash-input-radius;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
border-color: @blue;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: @blue;
|
||||
}
|
||||
}
|
||||
|
||||
// Plotly
|
||||
text.slicetext {
|
||||
text-shadow: 1px 1px 5px #333;
|
||||
}
|
||||
|
||||
|
||||
// Responsive fixes
|
||||
@media (max-width: 767px) {
|
||||
.text-center-xs {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.query-page-wrapper {
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
favorites-control {
|
||||
margin-top: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
left: 2%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
//Fix navbar collapse
|
||||
.navbar .collapse.in {
|
||||
border: none;
|
||||
|
||||
.dropdown-menu--profile {
|
||||
li {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--profile {
|
||||
.caret--nav:after {
|
||||
right: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown--profile__username {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav__main li a {
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
text-align: left;
|
||||
float: none !important;
|
||||
}
|
||||
|
||||
.navbar-form {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@media (max-width: 880px) {
|
||||
.navbar-link-ANGULAR_REMOVE_ME,
|
||||
.navbar-default .navbar-nav > li > a,
|
||||
.navbar-form {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
margin-left: -15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 810px) {
|
||||
.menu-search {
|
||||
width: 175px;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
margin-left: 13px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1084px) {
|
||||
.dropdown--profile__username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Cross-browser fixes
|
||||
|
||||
// Firefox
|
||||
@-moz-document url-prefix() {
|
||||
.caret--nav::after {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.navbar .caret--nav::after {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.navbar .btn--create .caret--nav::after {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// IE10+
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.caret--nav::after {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.navbar .caret--nav::after {
|
||||
top: 22px;
|
||||
}
|
||||
|
||||
.navbar .btn--create .caret--nav::after {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-select-choices-row.disabled > span {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
.list-group-item.inactive,
|
||||
.ui-select-choices-row.disabled {
|
||||
background-color: #eee !important;
|
||||
border-color: transparent;
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
color: #333;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.select-option-divider {
|
||||
margin: 10px 0 !important;
|
||||
}
|
||||
|
||||
.table-data .label-tag {
|
||||
display: inline-block;
|
||||
max-width: 135px;
|
||||
}
|
||||
|
||||
.markdown strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -19,8 +19,6 @@
|
||||
@import 'inc/ie-warning';
|
||||
@import 'inc/flex';
|
||||
|
||||
@import 'redash/redash-newstyle';
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
|
||||
22
client/app/components/AceEditorInput.jsx
Normal file
22
client/app/components/AceEditorInput.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import AceEditor from 'react-ace';
|
||||
|
||||
import './AceEditorInput.less';
|
||||
|
||||
function AceEditorInput(props, ref) {
|
||||
return (
|
||||
<div className="ace-editor-input">
|
||||
<AceEditor
|
||||
ref={ref}
|
||||
mode="sql"
|
||||
theme="textmate"
|
||||
height="100px"
|
||||
editorProps={{ $blockScrolling: Infinity }}
|
||||
showPrintMargin={false}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(AceEditorInput);
|
||||
11
client/app/components/AceEditorInput.less
Normal file
11
client/app/components/AceEditorInput.less
Normal file
@@ -0,0 +1,11 @@
|
||||
.ace-editor-input {
|
||||
// hide ghost cursor when not focused
|
||||
.ace_hidden-cursors {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// allow Ant Form feedback icon to hover scrollbar
|
||||
.ace_scrollbar {
|
||||
z-index: auto;
|
||||
}
|
||||
}
|
||||
83
client/app/components/BeaconConsent.jsx
Normal file
83
client/app/components/BeaconConsent.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Card from 'antd/lib/card';
|
||||
import Button from 'antd/lib/button';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import { HelpTrigger } from '@/components/HelpTrigger';
|
||||
import DynamicComponent from '@/components/DynamicComponent';
|
||||
import OrgSettings from '@/services/organizationSettings';
|
||||
|
||||
const Text = Typography.Text;
|
||||
|
||||
export function BeaconConsent() {
|
||||
const [hide, setHide] = useState(false);
|
||||
|
||||
if (!clientConfig.showBeaconConsentMessage || hide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hideConsentCard = () => {
|
||||
clientConfig.showBeaconConsentMessage = false;
|
||||
setHide(true);
|
||||
};
|
||||
|
||||
const confirmConsent = (confirm) => {
|
||||
let message = '🙏 Thank you.';
|
||||
|
||||
if (!confirm) {
|
||||
message = 'Settings Saved.';
|
||||
}
|
||||
|
||||
OrgSettings.save({ beacon_consent: confirm }, message)
|
||||
// .then(() => {
|
||||
// // const settings = get(response, 'settings');
|
||||
// // this.setState({ settings, formValues: { ...settings } });
|
||||
// })
|
||||
.finally(hideConsentCard);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicComponent name="BeaconConsent">
|
||||
<div className="m-t-10 tiled">
|
||||
<Card
|
||||
title={(
|
||||
<>
|
||||
Would you be ok with sharing anonymous usage data with the Redash team?{' '}
|
||||
<HelpTrigger type="USAGE_DATA_SHARING" />
|
||||
</>
|
||||
)}
|
||||
bordered={false}
|
||||
>
|
||||
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
|
||||
<div className="m-t-5">
|
||||
<ul>
|
||||
<li> Number of users, queries, dashboards, alerts, widgets and visualizations.</li>
|
||||
<li> Types of data sources, alert destinations and visualizations.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Text>All data is aggregated and will never include any sensitive or private data.</Text>
|
||||
<div className="m-t-5">
|
||||
<Button type="primary" className="m-r-5" onClick={() => confirmConsent(true)}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="default" onClick={() => confirmConsent(false)}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
<div className="m-t-15">
|
||||
<Text type="secondary">
|
||||
You can change this setting anytime from the <a href="settings/organization">Organization Settings</a> page.
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DynamicComponent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('beaconConsent', react2angular(BeaconConsent));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
80
client/app/components/CodeBlock.jsx
Normal file
80
client/app/components/CodeBlock.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from 'antd/lib/button';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import './CodeBlock.less';
|
||||
|
||||
export default class CodeBlock extends React.Component {
|
||||
static propTypes = {
|
||||
copyable: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
copyable: false,
|
||||
children: null,
|
||||
};
|
||||
|
||||
state = { copied: null };
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.ref = React.createRef();
|
||||
this.copyFeatureEnabled = props.copyable && document.queryCommandSupported('copy');
|
||||
this.resetCopyState = null;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.resetCopyState) {
|
||||
clearTimeout(this.resetCopyState);
|
||||
}
|
||||
}
|
||||
|
||||
copy = () => {
|
||||
// select text
|
||||
window.getSelection().selectAllChildren(this.ref.current);
|
||||
|
||||
// copy
|
||||
try {
|
||||
const success = document.execCommand('copy');
|
||||
if (!success) {
|
||||
throw new Error();
|
||||
}
|
||||
this.setState({ copied: 'Copied!' });
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
copied: 'Copy failed',
|
||||
});
|
||||
}
|
||||
|
||||
// reset selection
|
||||
window.getSelection().removeAllRanges();
|
||||
|
||||
// reset tooltip
|
||||
this.resetCopyState = setTimeout(() => this.setState({ copied: null }), 2000);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { copyable, children, ...props } = this.props;
|
||||
|
||||
const copyButton = (
|
||||
<Tooltip title={this.state.copied || 'Copy'}>
|
||||
<Button
|
||||
icon="copy"
|
||||
type="dashed"
|
||||
size="small"
|
||||
onClick={this.copy}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="code-block">
|
||||
<code {...props} ref={this.ref}>
|
||||
{children}
|
||||
</code>
|
||||
{this.copyFeatureEnabled && copyButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
23
client/app/components/CodeBlock.less
Normal file
23
client/app/components/CodeBlock.less
Normal file
@@ -0,0 +1,23 @@
|
||||
@import '~antd/lib/button/style/index';
|
||||
|
||||
.code-block {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 2px;
|
||||
padding: 3px 27px 3px 3px;
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.@{btn-prefix-cls} {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
padding-left: 3px !important;
|
||||
padding-right: 3px !important;
|
||||
}
|
||||
}
|
||||
24
client/app/components/Collapse.jsx
Normal file
24
client/app/components/Collapse.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
import AntCollapse from 'antd/lib/collapse';
|
||||
|
||||
export default function Collapse({ collapsed, children, className, ...props }) {
|
||||
return (
|
||||
<AntCollapse {...props} activeKey={collapsed ? null : 'content'} className={cx(className, 'ant-collapse-headerless')}>
|
||||
<AntCollapse.Panel key="content" header="">{children}</AntCollapse.Panel>
|
||||
</AntCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
Collapse.propTypes = {
|
||||
collapsed: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
Collapse.defaultProps = {
|
||||
collapsed: true,
|
||||
children: null,
|
||||
className: '',
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,49 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import DatePicker from 'antd/lib/date-picker';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
|
||||
export function DateInput({
|
||||
const DateInput = React.forwardRef(({
|
||||
defaultValue,
|
||||
value,
|
||||
onSelect,
|
||||
className,
|
||||
}) {
|
||||
...props
|
||||
}, ref) => {
|
||||
const format = clientConfig.dateFormat || 'YYYY-MM-DD';
|
||||
const additionalAttributes = {};
|
||||
if (value && value.isValid()) {
|
||||
additionalAttributes.defaultValue = value;
|
||||
if (defaultValue && defaultValue.isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (value && value.isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<DatePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
placeholder="Select Date"
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DateInput.propTypes = {
|
||||
defaultValue: Moment,
|
||||
value: Moment,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DateInput.defaultProps = {
|
||||
value: null,
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dateInput', react2angular(DateInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default DateInput;
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
import { isArray } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import DatePicker from 'antd/lib/date-picker';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export function DateRangeInput({
|
||||
const DateRangeInput = React.forwardRef(({
|
||||
defaultValue,
|
||||
value,
|
||||
onSelect,
|
||||
className,
|
||||
}) {
|
||||
...props
|
||||
}, ref) => {
|
||||
const format = clientConfig.dateFormat || 'YYYY-MM-DD';
|
||||
const additionalAttributes = {};
|
||||
if (isArray(value) && value[0].isValid() && value[1].isValid()) {
|
||||
additionalAttributes.defaultValue = value;
|
||||
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<RangePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DateRangeInput.propTypes = {
|
||||
defaultValue: PropTypes.arrayOf(Moment),
|
||||
value: PropTypes.arrayOf(Moment),
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
DateRangeInput.defaultProps = {
|
||||
value: null,
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dateRangeInput', react2angular(DateRangeInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default DateRangeInput;
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import DatePicker from 'antd/lib/date-picker';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
|
||||
export function DateTimeInput({
|
||||
const DateTimeInput = React.forwardRef(({
|
||||
defaultValue,
|
||||
value,
|
||||
withSeconds,
|
||||
onSelect,
|
||||
className,
|
||||
}) {
|
||||
...props
|
||||
}, ref) => {
|
||||
const format = (clientConfig.dateFormat || 'YYYY-MM-DD') +
|
||||
(withSeconds ? ' HH:mm:ss' : ' HH:mm');
|
||||
const additionalAttributes = {};
|
||||
if (value && value.isValid()) {
|
||||
additionalAttributes.defaultValue = value;
|
||||
if (defaultValue && defaultValue.isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (value && value.isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<DatePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
showTime
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
placeholder="Select Date and Time"
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DateTimeInput.propTypes = {
|
||||
defaultValue: Moment,
|
||||
value: Moment,
|
||||
withSeconds: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
@@ -37,14 +44,11 @@ DateTimeInput.propTypes = {
|
||||
};
|
||||
|
||||
DateTimeInput.defaultProps = {
|
||||
value: null,
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
withSeconds: false,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dateTimeInput', react2angular(DateTimeInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default DateTimeInput;
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
import { isArray } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import DatePicker from 'antd/lib/date-picker';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export function DateTimeRangeInput({
|
||||
const DateTimeRangeInput = React.forwardRef(({
|
||||
defaultValue,
|
||||
value,
|
||||
withSeconds,
|
||||
onSelect,
|
||||
className,
|
||||
}) {
|
||||
...props
|
||||
}, ref) => {
|
||||
const format = (clientConfig.dateFormat || 'YYYY-MM-DD') +
|
||||
(withSeconds ? ' HH:mm:ss' : ' HH:mm');
|
||||
const additionalAttributes = {};
|
||||
if (isArray(value) && value[0].isValid() && value[1].isValid()) {
|
||||
additionalAttributes.defaultValue = value;
|
||||
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
|
||||
additionalAttributes.defaultValue = defaultValue;
|
||||
}
|
||||
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
return (
|
||||
<RangePicker
|
||||
ref={ref}
|
||||
className={className}
|
||||
showTime
|
||||
{...additionalAttributes}
|
||||
format={format}
|
||||
onChange={onSelect}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DateTimeRangeInput.propTypes = {
|
||||
defaultValue: PropTypes.arrayOf(Moment),
|
||||
value: PropTypes.arrayOf(Moment),
|
||||
withSeconds: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
@@ -39,14 +46,11 @@ DateTimeRangeInput.propTypes = {
|
||||
};
|
||||
|
||||
DateTimeRangeInput.defaultProps = {
|
||||
value: null,
|
||||
defaultValue: null,
|
||||
value: undefined,
|
||||
withSeconds: false,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dateTimeRangeInput', react2angular(DateTimeRangeInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default DateTimeRangeInput;
|
||||
|
||||
@@ -43,7 +43,7 @@ export default class DynamicComponent extends React.Component {
|
||||
const { name, children, ...props } = this.props;
|
||||
const RealComponent = componentsRegistry.get(name);
|
||||
if (!RealComponent) {
|
||||
return null;
|
||||
return children;
|
||||
}
|
||||
return <RealComponent {...props}>{children}</RealComponent>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
import { includes, startsWith, words, capitalize, clone, isNull } from 'lodash';
|
||||
import { includes, words, capitalize, clone, isNull } from 'lodash';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Checkbox from 'antd/lib/checkbox';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Form from 'antd/lib/form';
|
||||
import Checkbox from 'antd/lib/checkbox';
|
||||
import Button from 'antd/lib/button';
|
||||
import Select from 'antd/lib/select';
|
||||
import Input from 'antd/lib/input';
|
||||
@@ -20,14 +20,17 @@ function getDefaultTitle(text) {
|
||||
return capitalize(words(text).join(' ')); // humanize
|
||||
}
|
||||
|
||||
function isTypeDate(type) {
|
||||
return startsWith(type, 'date') && !isTypeDateRange(type);
|
||||
}
|
||||
|
||||
function isTypeDateRange(type) {
|
||||
return /-range/.test(type);
|
||||
}
|
||||
|
||||
function joinExampleList(multiValuesOptions) {
|
||||
const { prefix, suffix } = multiValuesOptions;
|
||||
return ['value1', 'value2', 'value3']
|
||||
.map(value => `${prefix}${value}${suffix}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function NameInput({ name, type, onChange, existingNames, setValidation }) {
|
||||
let helpText = '';
|
||||
let validateStatus = '';
|
||||
@@ -150,6 +153,7 @@ function EditParameterSettingsDialog(props) {
|
||||
<Input
|
||||
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
|
||||
onChange={e => setParam({ ...param, title: e.target.value })}
|
||||
data-test="ParameterTitleInput"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Type" {...formItemProps}>
|
||||
@@ -161,29 +165,19 @@ function EditParameterSettingsDialog(props) {
|
||||
<Option disabled key="dv1">
|
||||
<Divider className="select-option-divider" />
|
||||
</Option>
|
||||
<Option value="date">Date</Option>
|
||||
<Option value="datetime-local">Date and Time</Option>
|
||||
<Option value="date" data-test="DateParameterTypeOption">Date</Option>
|
||||
<Option value="datetime-local" data-test="DateTimeParameterTypeOption">Date and Time</Option>
|
||||
<Option value="datetime-with-seconds">Date and Time (with seconds)</Option>
|
||||
<Option disabled key="dv2">
|
||||
<Divider className="select-option-divider" />
|
||||
</Option>
|
||||
<Option value="date-range">Date Range</Option>
|
||||
<Option value="date-range" data-test="DateRangeParameterTypeOption">Date Range</Option>
|
||||
<Option value="datetime-range">Date and Time Range</Option>
|
||||
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{isTypeDate(param.type) && (
|
||||
<Form.Item label=" " colon={false} {...formItemProps}>
|
||||
<Checkbox
|
||||
defaultChecked={param.useCurrentDateTime}
|
||||
onChange={e => setParam({ ...param, useCurrentDateTime: e.target.checked })}
|
||||
>
|
||||
Default to Today/Now if no other value is set
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{param.type === 'enum' && (
|
||||
<Form.Item label="Values" help="Dropdown list values (newline delimeted)" {...formItemProps}>
|
||||
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={param.enumOptions}
|
||||
@@ -200,6 +194,48 @@ function EditParameterSettingsDialog(props) {
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{(param.type === 'enum' || param.type === 'query') && (
|
||||
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
|
||||
<Checkbox
|
||||
defaultChecked={!!param.multiValuesOptions}
|
||||
onChange={e => setParam({ ...param,
|
||||
multiValuesOptions: e.target.checked ? {
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
separator: ',',
|
||||
} : null })}
|
||||
data-test="AllowMultipleValuesCheckbox"
|
||||
>
|
||||
Allow multiple values
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{(param.type === 'enum' || param.type === 'query') && param.multiValuesOptions && (
|
||||
<Form.Item
|
||||
label="Quotation"
|
||||
help={(
|
||||
<React.Fragment>
|
||||
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{...formItemProps}
|
||||
>
|
||||
<Select
|
||||
value={param.multiValuesOptions.prefix}
|
||||
onChange={quoteOption => setParam({ ...param,
|
||||
multiValuesOptions: {
|
||||
...param.multiValuesOptions,
|
||||
prefix: quoteOption,
|
||||
suffix: quoteOption,
|
||||
} })}
|
||||
data-test="QuotationSelect"
|
||||
>
|
||||
<Option value="">None (default)</Option>
|
||||
<Option value="'">Single Quotation Mark</Option>
|
||||
<Option value={'"'} data-test="DoubleQuotationMarkOption">Double Quotation Mark</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function QueryControlDropdown(props) {
|
||||
{!props.query.isNew() && (
|
||||
<Menu.Item>
|
||||
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
||||
<Icon type="share-alt" /> Embed elsewhere
|
||||
<Icon type="share-alt" /> Embed Elsewhere
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -53,7 +53,11 @@ export function QueryControlDropdown(props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown trigger={['click']} overlay={menu}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
overlay={menu}
|
||||
overlayClassName="query-control-dropdown-overlay"
|
||||
>
|
||||
<Button data-test="QueryControlDropdownButton">
|
||||
<Icon type="ellipsis" rotate={90} />
|
||||
</Button>
|
||||
|
||||
@@ -37,7 +37,6 @@ export class FavoritesControl extends React.Component {
|
||||
const title = item.is_favorite ? 'Remove from favorites' : 'Add to favorites';
|
||||
return (
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
title={title}
|
||||
className="btn-favourite"
|
||||
onClick={event => this.toggleItem(event, item, onChange)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isArray, map, includes, every, some } from 'lodash';
|
||||
import { isArray, indexOf, get, map, includes, every, some, toNumber, toLower } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Select from 'antd/lib/select';
|
||||
import { formatDateTime, formatDate } from '@/filters/datetime';
|
||||
|
||||
const ALL_VALUES = '###Redash::Filters::SelectAll###';
|
||||
const NONE_VALUES = '###Redash::Filters::Clear###';
|
||||
@@ -15,21 +16,28 @@ export const FilterType = PropTypes.shape({
|
||||
current: PropTypes.oneOfType([
|
||||
PropTypes.any,
|
||||
PropTypes.arrayOf(PropTypes.any),
|
||||
]).isRequired,
|
||||
]),
|
||||
values: PropTypes.arrayOf(PropTypes.any).isRequired,
|
||||
});
|
||||
|
||||
export const FiltersType = PropTypes.arrayOf(FilterType);
|
||||
|
||||
function createFilterChangeHandler(filters, onChange) {
|
||||
return (filter, value) => {
|
||||
if (filter.multiple && includes(value, ALL_VALUES)) {
|
||||
value = [...filter.values];
|
||||
return (filter, values) => {
|
||||
if (isArray(values)) {
|
||||
values = map(values, value => filter.values[toNumber(value.key)] || value.key);
|
||||
} else {
|
||||
const _values = filter.values[toNumber(values.key)];
|
||||
values = _values !== undefined ? _values : values.key;
|
||||
}
|
||||
if (filter.multiple && includes(value, NONE_VALUES)) {
|
||||
value = [];
|
||||
|
||||
if (filter.multiple && includes(values, ALL_VALUES)) {
|
||||
values = [...filter.values];
|
||||
}
|
||||
filters = map(filters, f => (f.name === filter.name ? { ...filter, current: value } : f));
|
||||
if (filter.multiple && includes(values, NONE_VALUES)) {
|
||||
values = [];
|
||||
}
|
||||
filters = map(filters, f => (f.name === filter.name ? { ...filter, current: values } : f));
|
||||
onChange(filters);
|
||||
};
|
||||
}
|
||||
@@ -63,6 +71,21 @@ 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;
|
||||
@@ -72,23 +95,32 @@ export function Filters({ filters, onChange }) {
|
||||
|
||||
return (
|
||||
<div className="filters-wrapper">
|
||||
<div className="parameter-container container bg-white">
|
||||
<div className="container bg-white">
|
||||
<div className="row">
|
||||
{map(filters, (filter) => {
|
||||
const options = map(filter.values, value => (
|
||||
<Select.Option key={value}>{value}</Select.Option>
|
||||
const options = map(filter.values, (value, index) => (
|
||||
<Select.Option key={index}>{formatValue(value, get(filter, 'column.type'))}</Select.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
<div key={filter.name} className="col-sm-6 p-l-0 filter-container">
|
||||
<label>{filter.friendlyName}</label>
|
||||
{(options.length === 0) && (
|
||||
<Select className="w-100" disabled value="No values" />
|
||||
)}
|
||||
{(options.length > 0) && (
|
||||
<Select
|
||||
labelInValue
|
||||
className="w-100"
|
||||
mode={filter.multiple ? 'multiple' : 'default'}
|
||||
value={filter.current}
|
||||
value={isArray(filter.current) ?
|
||||
map(filter.current,
|
||||
value => ({ key: `${indexOf(filter.values, value)}`, label: formatValue(value) })) :
|
||||
({ key: `${indexOf(filter.values, filter.current)}`, label: formatValue(filter.current) })}
|
||||
allowClear={filter.multiple}
|
||||
filterOption={(searchText, option) => includes(toLower(option.props.children), toLower(searchText))}
|
||||
showSearch
|
||||
onChange={value => onChange(filter, value)}
|
||||
onChange={values => onChange(filter, values)}
|
||||
>
|
||||
{!filter.multiple && options}
|
||||
{filter.multiple && [
|
||||
@@ -97,6 +129,7 @@ export function Filters({ filters, onChange }) {
|
||||
<Select.OptGroup key="Values" title="Values">{options}</Select.OptGroup>,
|
||||
]}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import Drawer from 'antd/lib/drawer';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import { BigMessage } from '@/components/BigMessage';
|
||||
import DynamicComponent from '@/components/DynamicComponent';
|
||||
|
||||
@@ -12,6 +13,7 @@ import './HelpTrigger.less';
|
||||
const DOMAIN = 'https://redash.io';
|
||||
const HELP_PATH = '/help';
|
||||
const IFRAME_TIMEOUT = 20000;
|
||||
const IFRAME_URL_UPDATE_MESSAGE = 'iframe_url';
|
||||
|
||||
export const TYPES = {
|
||||
HOME: [
|
||||
@@ -26,6 +28,14 @@ export const TYPES = {
|
||||
'/user-guide/dashboards/sharing-dashboards',
|
||||
'Guide: Sharing and Embedding Dashboards',
|
||||
],
|
||||
AUTHENTICATION_OPTIONS: [
|
||||
'/user-guide/users/authentication-options',
|
||||
'Guide: Authentication Options',
|
||||
],
|
||||
USAGE_DATA_SHARING: [
|
||||
'/open-source/admin-guide/usage-data',
|
||||
'Help: Anonymous Usage Data Sharing',
|
||||
],
|
||||
DS_ATHENA: [
|
||||
'/data-sources/amazon-athena-setup',
|
||||
'Guide: Help Setting up Amazon Athena',
|
||||
@@ -72,9 +82,9 @@ export class HelpTrigger extends React.Component {
|
||||
children: <i className="fa fa-question-circle" />,
|
||||
};
|
||||
|
||||
iframeRef = null
|
||||
iframeRef = null;
|
||||
|
||||
iframeLoadingTimeout = null
|
||||
iframeLoadingTimeout = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -85,9 +95,15 @@ export class HelpTrigger extends React.Component {
|
||||
visible: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
currentUrl: null,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('message', this.onPostMessageReceived, DOMAIN);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('message', this.onPostMessageReceived);
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
}
|
||||
|
||||
@@ -106,6 +122,15 @@ export class HelpTrigger extends React.Component {
|
||||
clearTimeout(this.iframeLoadingTimeout);
|
||||
};
|
||||
|
||||
onPostMessageReceived = (event) => {
|
||||
const { type, message: currentUrl } = event.data || {};
|
||||
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ currentUrl });
|
||||
}
|
||||
|
||||
openDrawer = () => {
|
||||
this.setState({ visible: true });
|
||||
const [pagePath] = TYPES[this.props.type];
|
||||
@@ -115,23 +140,29 @@ export class HelpTrigger extends React.Component {
|
||||
setTimeout(() => this.loadIframe(url), 300);
|
||||
};
|
||||
|
||||
closeDrawer = () => {
|
||||
closeDrawer = (event) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
this.setState({ visible: false });
|
||||
this.setState({ visible: false, currentUrl: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const [, tooltip] = TYPES[this.props.type];
|
||||
const className = cx('help-trigger', this.props.className);
|
||||
const url = this.state.currentUrl;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Tooltip title={tooltip}>
|
||||
<a href="javascript: void(0)" onClick={this.openDrawer} className={className}>
|
||||
<a onClick={this.openDrawer} className={className}>
|
||||
{this.props.children}
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Drawer
|
||||
placement="right"
|
||||
closable={false}
|
||||
onClose={this.closeDrawer}
|
||||
visible={this.state.visible}
|
||||
className="help-drawer"
|
||||
@@ -139,6 +170,22 @@ export class HelpTrigger extends React.Component {
|
||||
width={400}
|
||||
>
|
||||
<div className="drawer-wrapper">
|
||||
<div className="drawer-menu">
|
||||
{url && (
|
||||
<Tooltip title="Open page in a new window" placement="left">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href={url} target="_blank">
|
||||
<i className="fa fa-external-link" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Close" placement="bottom">
|
||||
<a href="#" onClick={this.closeDrawer}>
|
||||
<Icon type="close" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* iframe */}
|
||||
{!this.state.error && (
|
||||
<iframe
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
@import '~antd/lib/drawer/style/drawer';
|
||||
|
||||
@help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15
|
||||
|
||||
.help-trigger {
|
||||
font-size: 15px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.help-drawer {
|
||||
@@ -20,6 +28,54 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
top: 13px;
|
||||
right: 13px;
|
||||
border-radius: 3px;
|
||||
background: rgba(@help-doc-bg, .75); // makes it dissolve over help doc bg
|
||||
border: 2px solid @help-doc-bg;
|
||||
display: flex;
|
||||
|
||||
a {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: @text-color-secondary;
|
||||
transition: color @animation-duration-slow;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: @icon-color-hover;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.fa-external-link {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// divider
|
||||
&:not(:first-child):before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 9px;
|
||||
left: 0;
|
||||
top: 9px;
|
||||
border-left: 1px dotted rgba(0,0,0,.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 0;
|
||||
visibility: hidden;
|
||||
|
||||
20
client/app/components/HtmlContent.jsx
Normal file
20
client/app/components/HtmlContent.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { $sanitize } from '@/services/ng';
|
||||
|
||||
export default function HtmlContent({ children, ...props }) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
dangerouslySetInnerHTML={{ __html: $sanitize(children) }} // eslint-disable-line react/no-danger
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
HtmlContent.propTypes = {
|
||||
children: PropTypes.string,
|
||||
};
|
||||
|
||||
HtmlContent.defaultProps = {
|
||||
children: '',
|
||||
};
|
||||
32
client/app/components/ParameterApplyButton.jsx
Normal file
32
client/app/components/ParameterApplyButton.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from 'antd/lib/button';
|
||||
import Badge from 'antd/lib/badge';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import { KeyboardShortcuts } from '@/services/keyboard-shortcuts';
|
||||
|
||||
function ParameterApplyButton({ paramCount, onClick }) {
|
||||
// show spinner when count is empty so the fade out is consistent
|
||||
const icon = !paramCount ? 'spinner fa-pulse' : 'check';
|
||||
|
||||
return (
|
||||
<div className="parameter-apply-button" data-show={!!paramCount} data-test="ParameterApplyButton">
|
||||
<Badge count={paramCount}>
|
||||
<Tooltip title={`${KeyboardShortcuts.modKey} + Enter`}>
|
||||
<span>
|
||||
<Button onClick={onClick}>
|
||||
<i className={`fa fa-${icon}`} /> Apply Changes
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ParameterApplyButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
paramCount: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default ParameterApplyButton;
|
||||
@@ -14,10 +14,9 @@ import Input from 'antd/lib/input';
|
||||
import Radio from 'antd/lib/radio';
|
||||
import Form from 'antd/lib/form';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import { ParameterValueInput } from '@/components/ParameterValueInput';
|
||||
import ParameterValueInput from '@/components/ParameterValueInput';
|
||||
import { ParameterMappingType } from '@/services/widget';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import { Query, Parameter } from '@/services/query';
|
||||
import { Parameter } from '@/services/query';
|
||||
import { HelpTrigger } from '@/components/HelpTrigger';
|
||||
|
||||
import './ParameterMappingInput.less';
|
||||
@@ -120,8 +119,6 @@ export class ParameterMappingInput extends React.Component {
|
||||
mapping: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
existingParamNames: PropTypes.arrayOf(PropTypes.string),
|
||||
onChange: PropTypes.func,
|
||||
clientConfig: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
Query: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
inputError: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -129,8 +126,6 @@ export class ParameterMappingInput extends React.Component {
|
||||
mapping: {},
|
||||
existingParamNames: [],
|
||||
onChange: () => {},
|
||||
clientConfig: null,
|
||||
Query: null,
|
||||
inputError: null,
|
||||
};
|
||||
|
||||
@@ -159,6 +154,10 @@ export class ParameterMappingInput extends React.Component {
|
||||
updateParamMapping = (update) => {
|
||||
const { onChange, mapping } = this.props;
|
||||
const newMapping = extend({}, mapping, update);
|
||||
if (newMapping.value !== mapping.value) {
|
||||
newMapping.param = newMapping.param.clone();
|
||||
newMapping.param.setValue(newMapping.value);
|
||||
}
|
||||
onChange(newMapping);
|
||||
};
|
||||
|
||||
@@ -228,9 +227,8 @@ export class ParameterMappingInput extends React.Component {
|
||||
value={mapping.param.normalizedValue}
|
||||
enumOptions={mapping.param.enumOptions}
|
||||
queryId={mapping.param.queryId}
|
||||
parameter={mapping.param}
|
||||
onSelect={value => this.updateParamMapping({ value })}
|
||||
clientConfig={this.props.clientConfig}
|
||||
Query={this.props.Query}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -345,8 +343,6 @@ class MappingEditor extends React.Component {
|
||||
mapping={mapping}
|
||||
existingParamNames={this.props.existingParamNames}
|
||||
onChange={this.onChange}
|
||||
clientConfig={clientConfig}
|
||||
Query={Query}
|
||||
inputError={inputError}
|
||||
/>
|
||||
<footer>
|
||||
@@ -540,7 +536,13 @@ export class ParameterMappingListInput extends React.Component {
|
||||
param = param.clone().setValue(mapping.value);
|
||||
}
|
||||
|
||||
const value = Parameter.getValue(param);
|
||||
let value = Parameter.getValue(param);
|
||||
|
||||
// in case of dynamic value display the name instead of value
|
||||
if (param.hasDynamicValue) {
|
||||
value = param.dynamicValue.name;
|
||||
}
|
||||
|
||||
return this.getStringValue(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
import Select from 'antd/lib/select';
|
||||
import Input from 'antd/lib/input';
|
||||
import InputNumber from 'antd/lib/input-number';
|
||||
import { DateInput } from './DateInput';
|
||||
import { DateRangeInput } from './DateRangeInput';
|
||||
import { DateTimeInput } from './DateTimeInput';
|
||||
import { DateTimeRangeInput } from './DateTimeRangeInput';
|
||||
import DateParameter from '@/components/dynamic-parameters/DateParameter';
|
||||
import DateRangeParameter from '@/components/dynamic-parameters/DateRangeParameter';
|
||||
import { toString } from 'lodash';
|
||||
import { QueryBasedParameterInput } from './QueryBasedParameterInput';
|
||||
|
||||
import './ParameterValueInput.less';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export class ParameterValueInput extends React.Component {
|
||||
const multipleValuesProps = {
|
||||
maxTagCount: 3,
|
||||
maxTagTextLength: 10,
|
||||
maxTagPlaceholder: num => `+${num.length} more`,
|
||||
};
|
||||
|
||||
class ParameterValueInput extends React.Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.string,
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
enumOptions: PropTypes.string,
|
||||
queryId: PropTypes.number,
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
allowMultipleValues: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
@@ -29,89 +36,82 @@ export class ParameterValueInput extends React.Component {
|
||||
enumOptions: '',
|
||||
queryId: null,
|
||||
parameter: null,
|
||||
allowMultipleValues: false,
|
||||
onSelect: () => {},
|
||||
className: '',
|
||||
};
|
||||
|
||||
renderDateTimeWithSecondsInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: props.parameter.hasPendingValue ? props.parameter.pendingValue : props.value,
|
||||
isDirty: props.parameter.hasPendingValue,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const { value, parameter } = this.props;
|
||||
// if value prop updated, reset dirty state
|
||||
if (prevProps.value !== value || prevProps.parameter !== parameter) {
|
||||
this.setState({
|
||||
value: parameter.hasPendingValue ? parameter.pendingValue : value,
|
||||
isDirty: parameter.hasPendingValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onSelect = (value) => {
|
||||
const isDirty = toString(value) !== toString(this.props.value);
|
||||
this.setState({ value, isDirty });
|
||||
this.props.onSelect(value, isDirty);
|
||||
}
|
||||
|
||||
renderDateParameter() {
|
||||
const { type, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<DateTimeInput
|
||||
<DateParameter
|
||||
type={type}
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
withSeconds
|
||||
parameter={parameter}
|
||||
onSelect={this.onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateTimeInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
renderDateRangeParameter() {
|
||||
const { type, parameter } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<DateTimeInput
|
||||
<DateRangeParameter
|
||||
type={type}
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
return (
|
||||
<DateInput
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateTimeRangeWithSecondsInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
return (
|
||||
<DateTimeRangeInput
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
withSeconds
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateTimeRangeInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
return (
|
||||
<DateTimeRangeInput
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderDateRangeInput() {
|
||||
const { value, onSelect } = this.props;
|
||||
return (
|
||||
<DateRangeInput
|
||||
className={this.props.className}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
parameter={parameter}
|
||||
onSelect={this.onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderEnumInput() {
|
||||
const { value, onSelect, enumOptions } = this.props;
|
||||
const { enumOptions, allowMultipleValues } = this.props;
|
||||
const { value } = this.state;
|
||||
const enumOptionsArray = enumOptions.split('\n').filter(v => v !== '');
|
||||
return (
|
||||
<Select
|
||||
className={this.props.className}
|
||||
mode={allowMultipleValues ? 'multiple' : 'default'}
|
||||
optionFilterProp="children"
|
||||
disabled={enumOptionsArray.length === 0}
|
||||
defaultValue={value}
|
||||
onChange={onSelect}
|
||||
value={value}
|
||||
onChange={this.onSelect}
|
||||
dropdownMatchSelectWidth={false}
|
||||
dropdownClassName="ant-dropdown-in-bootstrap-modal"
|
||||
showSearch
|
||||
showArrow
|
||||
style={{ minWidth: 60 }}
|
||||
notFoundContent={null}
|
||||
{...multipleValuesProps}
|
||||
>
|
||||
{enumOptionsArray.map(option => (<Option key={option} value={option}>{ option }</Option>))}
|
||||
</Select>
|
||||
@@ -119,81 +119,77 @@ export class ParameterValueInput extends React.Component {
|
||||
}
|
||||
|
||||
renderQueryBasedInput() {
|
||||
const { value, onSelect, queryId, parameter } = this.props;
|
||||
const { queryId, parameter, allowMultipleValues } = this.props;
|
||||
const { value } = this.state;
|
||||
return (
|
||||
<QueryBasedParameterInput
|
||||
className={this.props.className}
|
||||
mode={allowMultipleValues ? 'multiple' : 'default'}
|
||||
optionFilterProp="children"
|
||||
parameter={parameter}
|
||||
value={value}
|
||||
queryId={queryId}
|
||||
onSelect={onSelect}
|
||||
onSelect={this.onSelect}
|
||||
style={{ minWidth: 60 }}
|
||||
{...multipleValuesProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderNumberInput() {
|
||||
const { value, onSelect, className } = this.props;
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
const normalize = val => (isNaN(val) ? undefined : val);
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
className={'form-control ' + className}
|
||||
defaultValue={!isNaN(value) && value || 0}
|
||||
onChange={onSelect}
|
||||
className={className}
|
||||
value={normalize(value)}
|
||||
onChange={val => this.onSelect(normalize(val))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderTextInput() {
|
||||
const { value, onSelect, className } = this.props;
|
||||
const { className } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
return (
|
||||
<Input
|
||||
className={'form-control ' + className}
|
||||
defaultValue={value || ''}
|
||||
className={className}
|
||||
value={value}
|
||||
data-test="TextParamInput"
|
||||
onChange={event => onSelect(event.target.value)}
|
||||
onChange={e => this.onSelect(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
renderInput() {
|
||||
const { type } = this.props;
|
||||
switch (type) {
|
||||
case 'datetime-with-seconds': return this.renderDateTimeWithSecondsInput();
|
||||
case 'datetime-local': return this.renderDateTimeInput();
|
||||
case 'date': return this.renderDateInput();
|
||||
case 'datetime-range-with-seconds': return this.renderDateTimeRangeWithSecondsInput();
|
||||
case 'datetime-range': return this.renderDateTimeRangeInput();
|
||||
case 'date-range': return this.renderDateRangeInput();
|
||||
case 'datetime-with-seconds':
|
||||
case 'datetime-local':
|
||||
case 'date': return this.renderDateParameter();
|
||||
case 'datetime-range-with-seconds':
|
||||
case 'datetime-range':
|
||||
case 'date-range': return this.renderDateRangeParameter();
|
||||
case 'enum': return this.renderEnumInput();
|
||||
case 'query': return this.renderQueryBasedInput();
|
||||
case 'number': return this.renderNumberInput();
|
||||
default: return this.renderTextInput();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isDirty } = this.state;
|
||||
|
||||
return (
|
||||
<div className="parameter-input" data-dirty={isDirty || null}>
|
||||
{this.renderInput()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('parameterValueInput', {
|
||||
template: `
|
||||
<parameter-value-input-impl
|
||||
type="$ctrl.param.type"
|
||||
value="$ctrl.param.normalizedValue"
|
||||
parameter="$ctrl.param"
|
||||
enum-options="$ctrl.param.enumOptions"
|
||||
query-id="$ctrl.param.queryId"
|
||||
on-select="$ctrl.setValue"
|
||||
></parameter-value-input-impl>
|
||||
`,
|
||||
bindings: {
|
||||
param: '<',
|
||||
},
|
||||
controller($scope) {
|
||||
this.setValue = (value) => {
|
||||
this.param.setValue(value);
|
||||
$scope.$applyAsync();
|
||||
};
|
||||
},
|
||||
});
|
||||
ngModule.component('parameterValueInputImpl', react2angular(ParameterValueInput));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
export default ParameterValueInput;
|
||||
|
||||
26
client/app/components/ParameterValueInput.less
Normal file
26
client/app/components/ParameterValueInput.less
Normal file
@@ -0,0 +1,26 @@
|
||||
@import '~antd/lib/input-number/style/index'; // for ant @vars
|
||||
|
||||
@input-dirty: #fffce1;
|
||||
|
||||
.parameter-input {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.@{ant-prefix}-input,
|
||||
.@{ant-prefix}-input-number {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
.@{ant-prefix}-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&[data-dirty] {
|
||||
.@{ant-prefix}-input, // covers also ant date component
|
||||
.@{ant-prefix}-input-number,
|
||||
.@{ant-prefix}-select-selection {
|
||||
background-color: @input-dirty;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
client/app/components/Parameters.jsx
Normal file
208
client/app/components/Parameters.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { size, filter, forEach, extend } from 'lodash';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { sortableContainer, sortableElement, sortableHandle } from 'react-sortable-hoc';
|
||||
import { $location } from '@/services/ng';
|
||||
import { Parameter } from '@/services/query';
|
||||
import ParameterApplyButton from '@/components/ParameterApplyButton';
|
||||
import ParameterValueInput from '@/components/ParameterValueInput';
|
||||
import EditParameterSettingsDialog from './EditParameterSettingsDialog';
|
||||
import { toHuman } from '@/filters';
|
||||
|
||||
import './Parameters.less';
|
||||
|
||||
const DragHandle = sortableHandle(({ parameterName }) => (
|
||||
<div className="drag-handle" data-test={`DragHandle-${parameterName}`} />
|
||||
));
|
||||
|
||||
const SortableItem = sortableElement(({ className, parameterName, disabled, children }) => (
|
||||
<div className={className} data-editable={!disabled || null}>
|
||||
{!disabled && <DragHandle parameterName={parameterName} />}
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
const SortableContainer = sortableContainer(({ children }) => children);
|
||||
|
||||
function updateUrl(parameters) {
|
||||
const params = extend({}, $location.search());
|
||||
parameters.forEach((param) => {
|
||||
extend(params, param.toUrlParams());
|
||||
});
|
||||
Object.keys(params).forEach(key => params[key] == null && delete params[key]);
|
||||
$location.search(params);
|
||||
}
|
||||
|
||||
export class Parameters extends React.Component {
|
||||
static propTypes = {
|
||||
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
|
||||
editable: PropTypes.bool,
|
||||
disableUrlUpdate: PropTypes.bool,
|
||||
onValuesChange: PropTypes.func,
|
||||
onPendingValuesChange: PropTypes.func,
|
||||
onParametersEdit: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
parameters: [],
|
||||
editable: false,
|
||||
disableUrlUpdate: false,
|
||||
onValuesChange: () => {},
|
||||
onPendingValuesChange: () => {},
|
||||
onParametersEdit: () => {},
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { parameters } = props;
|
||||
this.state = { parameters, dragging: false };
|
||||
if (!props.disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps) => {
|
||||
const { parameters, disableUrlUpdate } = this.props;
|
||||
if (prevProps.parameters !== parameters) {
|
||||
this.setState({ parameters });
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
// Cmd/Ctrl/Alt + Enter
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
|
||||
e.stopPropagation();
|
||||
this.applyChanges();
|
||||
}
|
||||
};
|
||||
|
||||
setPendingValue = (param, value, isDirty) => {
|
||||
const { onPendingValuesChange } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
if (isDirty) {
|
||||
param.setPendingValue(value);
|
||||
} else {
|
||||
param.clearPendingValue();
|
||||
}
|
||||
onPendingValuesChange();
|
||||
return { parameters };
|
||||
});
|
||||
};
|
||||
|
||||
moveParameter = ({ oldIndex, newIndex }) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
if (oldIndex !== newIndex) {
|
||||
this.setState(({ parameters }) => {
|
||||
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
|
||||
onParametersEdit();
|
||||
return { parameters };
|
||||
});
|
||||
}
|
||||
this.setState({ dragging: false });
|
||||
};
|
||||
|
||||
onBeforeSortStart = () => {
|
||||
this.setState({ dragging: true });
|
||||
};
|
||||
|
||||
applyChanges = () => {
|
||||
const { onValuesChange, disableUrlUpdate } = this.props;
|
||||
this.setState(({ parameters }) => {
|
||||
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue);
|
||||
forEach(parameters, p => p.applyPendingValue());
|
||||
onValuesChange(parametersWithPendingValues);
|
||||
if (!disableUrlUpdate) {
|
||||
updateUrl(parameters);
|
||||
}
|
||||
return { parameters };
|
||||
});
|
||||
};
|
||||
|
||||
showParameterSettings = (parameter, index) => {
|
||||
const { onParametersEdit } = this.props;
|
||||
EditParameterSettingsDialog
|
||||
.showModal({ parameter })
|
||||
.result.then((updated) => {
|
||||
this.setState(({ parameters }) => {
|
||||
const updatedParameter = extend(parameter, updated);
|
||||
parameters[index] = new Parameter(updatedParameter, updatedParameter.parentQueryId);
|
||||
onParametersEdit();
|
||||
return { parameters };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
renderParameter(param, index) {
|
||||
const { editable } = this.props;
|
||||
return (
|
||||
<div
|
||||
key={param.name}
|
||||
className="di-block"
|
||||
data-test={`ParameterName-${param.name}`}
|
||||
>
|
||||
<div className="parameter-heading">
|
||||
<label>{param.title || toHuman(param.name)}</label>
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-default btn-xs m-l-5"
|
||||
onClick={() => this.showParameterSettings(param, index)}
|
||||
data-test={`ParameterSettings-${param.name}`}
|
||||
type="button"
|
||||
>
|
||||
<i className="fa fa-cog" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ParameterValueInput
|
||||
type={param.type}
|
||||
value={param.normalizedValue}
|
||||
parameter={param}
|
||||
enumOptions={param.enumOptions}
|
||||
queryId={param.queryId}
|
||||
allowMultipleValues={!!param.multiValuesOptions}
|
||||
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { parameters, dragging } = this.state;
|
||||
const { editable } = this.props;
|
||||
const dirtyParamCount = size(filter(parameters, 'hasPendingValue'));
|
||||
return (
|
||||
<SortableContainer
|
||||
axis="xy"
|
||||
useDragHandle
|
||||
lockToContainerEdges
|
||||
helperClass="parameter-dragged"
|
||||
updateBeforeSortStart={this.onBeforeSortStart}
|
||||
onSortEnd={this.moveParameter}
|
||||
>
|
||||
<div
|
||||
className="parameter-container"
|
||||
onKeyDown={dirtyParamCount ? this.handleKeyDown : null}
|
||||
data-draggable={editable || null}
|
||||
data-dragging={dragging || null}
|
||||
>
|
||||
{parameters.map((param, index) => (
|
||||
<SortableItem className="parameter-block" key={param.name} index={index} parameterName={param.name} disabled={!editable}>
|
||||
{this.renderParameter(param, index)}
|
||||
</SortableItem>
|
||||
))}
|
||||
|
||||
<ParameterApplyButton onClick={this.applyChanges} paramCount={dirtyParamCount} />
|
||||
</div>
|
||||
</SortableContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('parameters', react2angular(Parameters));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
124
client/app/components/Parameters.less
Normal file
124
client/app/components/Parameters.less
Normal file
@@ -0,0 +1,124 @@
|
||||
@import '../assets/less/ant';
|
||||
|
||||
.drag-handle {
|
||||
background: linear-gradient(90deg, transparent 0px, white 1px, white 2px)
|
||||
center,
|
||||
linear-gradient(transparent 0px, white 1px, white 2px) center, #111111;
|
||||
background-size: 2px 2px;
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 36px;
|
||||
vertical-align: bottom;
|
||||
margin-right: 5px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.parameter-block {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
padding: 0 12px 6px 0;
|
||||
vertical-align: top;
|
||||
|
||||
.parameter-container[data-draggable] & {
|
||||
margin: 4px 0 0 4px;
|
||||
padding: 3px 6px 6px;
|
||||
}
|
||||
|
||||
&.parameter-dragged {
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 4px;
|
||||
|
||||
label {
|
||||
margin-bottom: 1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 100%;
|
||||
max-width: 195px;
|
||||
white-space: nowrap;
|
||||
|
||||
.parameter-block[data-editable] & {
|
||||
min-width: calc(100% - 27px); // make room for settings button
|
||||
max-width: 195px - 27px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-container {
|
||||
position: relative;
|
||||
|
||||
&[data-draggable] {
|
||||
padding: 0 4px 4px 0;
|
||||
transition: background-color 200ms ease-out;
|
||||
transition-delay: 300ms; // short pause before returning to original bgcolor
|
||||
}
|
||||
|
||||
&[data-dragging] {
|
||||
transition-delay: 0s;
|
||||
background-color: #f6f8f9;
|
||||
}
|
||||
|
||||
.parameter-apply-button {
|
||||
display: none; // default for mobile
|
||||
|
||||
// "floating" on desktop
|
||||
@media (min-width: 768px) {
|
||||
position: absolute;
|
||||
bottom: -36px;
|
||||
left: -15px;
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
transition: opacity 150ms ease-out;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
background-color: #ffffff;
|
||||
padding: 4px;
|
||||
padding-left: 16px;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
pointer-events: none; // so tooltip doesn't remain after button hides
|
||||
}
|
||||
|
||||
&[data-show="true"] {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0 8px 0 6px;
|
||||
color: #2096f3;
|
||||
border-color: #50acf6;
|
||||
|
||||
// smaller on desktop
|
||||
@media (min-width: 768px) {
|
||||
font-size: 12px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
background-color: #eef7fe;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-badge-count {
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
background: #f77b74;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0px 0px 0 1px white, -1px 1px 0 1px #5d6f7d85;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { find, isFunction, toString } from 'lodash';
|
||||
import { find, isFunction, isArray, isEqual, toString, map, intersection } from 'lodash';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { react2angular } from 'react2angular';
|
||||
@@ -10,6 +10,7 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
static propTypes = {
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
mode: PropTypes.oneOf(['default', 'multiple']),
|
||||
queryId: PropTypes.number,
|
||||
onSelect: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
@@ -17,6 +18,7 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
|
||||
static defaultProps = {
|
||||
value: null,
|
||||
mode: 'default',
|
||||
parameter: null,
|
||||
queryId: null,
|
||||
onSelect: () => {},
|
||||
@@ -50,6 +52,13 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
if (this.props.queryId === queryId) {
|
||||
this.setState({ options, loading: false });
|
||||
|
||||
if (this.props.mode === 'multiple' && isArray(this.props.value)) {
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(this.props.value, optionValues);
|
||||
if (!isEqual(this.props.value, validValues)) {
|
||||
this.props.onSelect(validValues);
|
||||
}
|
||||
} else {
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
if (!found && isFunction(this.props.onSelect)) {
|
||||
this.props.onSelect(options[0].value);
|
||||
@@ -57,9 +66,10 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, value, onSelect } = this.props;
|
||||
const { className, value, mode, onSelect, ...otherProps } = this.props;
|
||||
const { loading, options } = this.state;
|
||||
return (
|
||||
<span>
|
||||
@@ -67,10 +77,15 @@ export class QueryBasedParameterInput extends React.Component {
|
||||
className={className}
|
||||
disabled={loading || (options.length === 0)}
|
||||
loading={loading}
|
||||
value={toString(value)}
|
||||
mode={mode}
|
||||
value={isArray(value) ? value : toString(value)}
|
||||
onChange={onSelect}
|
||||
dropdownMatchSelectWidth={false}
|
||||
dropdownClassName="ant-dropdown-in-bootstrap-modal"
|
||||
optionFilterProp="children"
|
||||
showSearch
|
||||
showArrow
|
||||
notFoundContent={null}
|
||||
{...otherProps}
|
||||
>
|
||||
{options.map(option => (<Option value={option.value} key={option.value}>{option.name}</Option>))}
|
||||
</Select>
|
||||
|
||||
@@ -54,7 +54,7 @@ class QueryEditor extends React.Component {
|
||||
isDirty: PropTypes.bool.isRequired,
|
||||
isQueryOwner: PropTypes.bool.isRequired,
|
||||
updateDataSource: PropTypes.func.isRequired,
|
||||
canExecuteQuery: PropTypes.func.isRequired,
|
||||
canExecuteQuery: PropTypes.bool.isRequired,
|
||||
executeQuery: PropTypes.func.isRequired,
|
||||
queryExecuting: PropTypes.bool.isRequired,
|
||||
saveQuery: PropTypes.func.isRequired,
|
||||
@@ -149,6 +149,7 @@ class QueryEditor extends React.Component {
|
||||
editor.commands.bindKey({ win: 'Ctrl+P', mac: null }, null);
|
||||
// Lineup only mac
|
||||
editor.commands.bindKey({ win: null, mac: 'Ctrl+P' }, 'golineup');
|
||||
editor.commands.bindKey({ win: 'Ctrl+Shift+F', mac: 'Cmd+Shift+F' }, this.formatQuery);
|
||||
|
||||
// Reset Completer in case dot is pressed
|
||||
editor.commands.on('afterExec', (e) => {
|
||||
@@ -226,7 +227,7 @@ class QueryEditor extends React.Component {
|
||||
render() {
|
||||
const modKey = KeyboardShortcuts.modKey;
|
||||
|
||||
const isExecuteDisabled = this.props.queryExecuting || !this.props.canExecuteQuery();
|
||||
const isExecuteDisabled = this.props.queryExecuting || !this.props.canExecuteQuery;
|
||||
|
||||
return (
|
||||
<section style={{ height: '100%' }} data-test="QueryEditor">
|
||||
@@ -266,7 +267,7 @@ class QueryEditor extends React.Component {
|
||||
{{ }}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="top" title="Format Query">
|
||||
<Tooltip placement="top" title={<>Format Query (<i>{modKey} + Shift + F</i>)</>}>
|
||||
<button type="button" className="btn btn-default m-r-5" onClick={this.formatQuery}>
|
||||
<span className="zmdi zmdi-format-indent-increase" />
|
||||
</button>
|
||||
|
||||
@@ -112,7 +112,6 @@ export function QuerySelector(props) {
|
||||
<div className="list-group">
|
||||
{searchResults.map(q => (
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
className={cx('query-selector-result', 'list-group-item', { inactive: q.is_draft })}
|
||||
key={q.id}
|
||||
onClick={() => selectQuery(q.id)}
|
||||
|
||||
@@ -63,7 +63,6 @@ export class TagsList extends React.Component {
|
||||
{map(allTags, tag => (
|
||||
<a
|
||||
key={tag.name}
|
||||
href="javascript:void(0)"
|
||||
className={classNames('list-group-item', 'max-character', { active: selectedTags.has(tag.name) })}
|
||||
onClick={event => this.toggleTag(event, tag.name)}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
|
||||
import { Moment } from '@/components/proptypes';
|
||||
import { clientConfig } from '@/services/auth';
|
||||
import useForceUpdate from '@/lib/hooks/useForceUpdate';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
|
||||
function toMoment(value) {
|
||||
value = !isNil(value) ? moment(value) : null;
|
||||
@@ -27,7 +28,11 @@ export function TimeAgo({ date, placeholder, autoUpdate }) {
|
||||
}
|
||||
}, [autoUpdate]);
|
||||
|
||||
return <span title={title} data-test="TimeAgo">{value}</span>;
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<span data-test="TimeAgo">{value}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
TimeAgo.propTypes = {
|
||||
|
||||
@@ -14,7 +14,10 @@ export function Timer({ from }) {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return moment.utc(moment.now() - startTime).format('HH:mm:ss');
|
||||
const diff = moment.now() - startTime;
|
||||
const format = diff > 1000 * 60 * 60 ? 'HH:mm:ss' : 'mm:ss'; // no HH under an hour
|
||||
|
||||
return moment.utc(diff).format(format);
|
||||
}
|
||||
|
||||
Timer.propTypes = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import debug from 'debug';
|
||||
import CreateDashboardDialog from '@/components/dashboards/CreateDashboardDialog';
|
||||
|
||||
import logoUrl from '@/assets/images/redash_icon_small.png';
|
||||
import frontendVersion from '@/version.json';
|
||||
@@ -35,14 +36,7 @@ function controller($rootScope, $location, $route, $uibModal, Auth, currentUser,
|
||||
|
||||
$rootScope.$on('reloadFavorites', this.reload);
|
||||
|
||||
this.newDashboard = () => {
|
||||
$uibModal.open({
|
||||
component: 'editDashboardDialog',
|
||||
resolve: {
|
||||
dashboard: () => ({ name: null, layout: null }),
|
||||
},
|
||||
});
|
||||
};
|
||||
this.newDashboard = () => CreateDashboardDialog.showModal();
|
||||
|
||||
this.searchQueries = () => {
|
||||
$location.path('/queries').search({ q: this.searchTerm });
|
||||
|
||||
@@ -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 {
|
||||
@@ -71,7 +73,7 @@ export default class CardsList extends React.Component {
|
||||
)}
|
||||
{isEmpty(filteredItems) ? (<EmptyState className="" />) : (
|
||||
<div className="row">
|
||||
<div className="col-lg-12 d-inline-flex flex-wrap">
|
||||
<div className="col-lg-12 d-inline-flex flex-wrap visual-card-list">
|
||||
{filteredItems.map(item => this.renderListItem(item))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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,8 +1,10 @@
|
||||
// ANGULAR_REMOVE_ME
|
||||
color-box {
|
||||
span {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
display: inline-block !important;
|
||||
vertical-align: text-bottom;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
|
||||
& ~ span {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@ class AddWidgetDialog extends React.Component {
|
||||
className="w-100"
|
||||
defaultValue={first(this.state.selectedQuery.visualizations).id}
|
||||
onChange={visualizationId => this.selectVisualization(this.state.selectedQuery, visualizationId)}
|
||||
dropdownClassName="ant-dropdown-in-bootstrap-modal"
|
||||
>
|
||||
{visualizationGroups.map(visualizations => (
|
||||
<OptGroup label={visualizations[0].type} key={visualizations[0].type}>
|
||||
|
||||
118
client/app/components/dashboards/AutoHeightController.js
Normal file
118
client/app/components/dashboards/AutoHeightController.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { includes, reduce, some } from 'lodash';
|
||||
|
||||
// TODO: Revisit this implementation when migrating widget component to React
|
||||
|
||||
const WIDGET_SELECTOR = '[data-widgetid="{0}"]';
|
||||
const WIDGET_CONTENT_SELECTOR = [
|
||||
'.widget-header', // header
|
||||
'visualization-renderer', // visualization
|
||||
'.scrollbox .alert', // error state
|
||||
'.spinner-container', // loading state
|
||||
'.tile__bottom-control', // footer
|
||||
].join(',');
|
||||
const INTERVAL = 200;
|
||||
|
||||
export default class AutoHeightController {
|
||||
widgets = {};
|
||||
|
||||
interval = null;
|
||||
|
||||
onHeightChange = null;
|
||||
|
||||
constructor(handler) {
|
||||
this.onHeightChange = handler;
|
||||
}
|
||||
|
||||
update(widgets) {
|
||||
const newWidgetIds = widgets
|
||||
.filter(widget => widget.options.position.autoHeight)
|
||||
.map(widget => widget.id.toString());
|
||||
|
||||
// added
|
||||
newWidgetIds
|
||||
.filter(id => !includes(Object.keys(this.widgets), id))
|
||||
.forEach(this.add);
|
||||
|
||||
// removed
|
||||
Object.keys(this.widgets)
|
||||
.filter(id => !includes(newWidgetIds, id))
|
||||
.forEach(this.remove);
|
||||
}
|
||||
|
||||
add = (id) => {
|
||||
if (this.isEmpty()) {
|
||||
this.start();
|
||||
}
|
||||
|
||||
const selector = WIDGET_SELECTOR.replace('{0}', id);
|
||||
this.widgets[id] = [
|
||||
function getHeight() {
|
||||
const widgetEl = document.querySelector(selector);
|
||||
if (!widgetEl) {
|
||||
return undefined; // safety
|
||||
}
|
||||
|
||||
// get all content elements
|
||||
const els = widgetEl.querySelectorAll(WIDGET_CONTENT_SELECTOR);
|
||||
|
||||
// calculate accumulated height
|
||||
return reduce(els, (acc, el) => {
|
||||
const height = el ? el.getBoundingClientRect().height : 0;
|
||||
return acc + height;
|
||||
}, 0);
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
remove = (id) => {
|
||||
// ignore if not an active autoHeight widget
|
||||
if (!this.exists(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// not actually deleting from this.widgets to prevent case of unwanted re-adding
|
||||
this.widgets[id.toString()] = false;
|
||||
|
||||
if (this.isEmpty()) {
|
||||
this.stop();
|
||||
}
|
||||
};
|
||||
|
||||
exists = id => !!this.widgets[id.toString()];
|
||||
|
||||
isEmpty = () => !some(this.widgets);
|
||||
|
||||
checkHeightChanges = () => {
|
||||
Object
|
||||
.keys(this.widgets)
|
||||
.filter(this.exists) // reject already removed items
|
||||
.forEach((id) => {
|
||||
const [getHeight, prevHeight] = this.widgets[id];
|
||||
const height = getHeight();
|
||||
if (height && height !== prevHeight) {
|
||||
this.widgets[id][1] = height; // save
|
||||
this.onHeightChange(id, height); // dispatch
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
start = () => {
|
||||
this.stop();
|
||||
this.interval = setInterval(this.checkHeightChanges, INTERVAL);
|
||||
};
|
||||
|
||||
stop = () => {
|
||||
clearInterval(this.interval);
|
||||
};
|
||||
|
||||
resume = () => {
|
||||
if (!this.isEmpty()) {
|
||||
this.start();
|
||||
}
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.stop();
|
||||
this.widgets = null;
|
||||
}
|
||||
}
|
||||
88
client/app/components/dashboards/CreateDashboardDialog.jsx
Normal file
88
client/app/components/dashboards/CreateDashboardDialog.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { trim } from 'lodash';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Input from 'antd/lib/input';
|
||||
import DynamicComponent from '@/components/DynamicComponent';
|
||||
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
|
||||
import { $location, $http } from '@/services/ng';
|
||||
import recordEvent from '@/services/recordEvent';
|
||||
import { policy } from '@/services/policy';
|
||||
|
||||
function CreateDashboardDialog({ dialog }) {
|
||||
const [name, setName] = useState('');
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const [saveInProgress, setSaveInProgress] = useState(false);
|
||||
const inputRef = useRef();
|
||||
const isCreateDashboardEnabled = policy.isCreateDashboardEnabled();
|
||||
|
||||
// ANGULAR_REMOVE_ME Replace all this with `autoFocus` attribute (it does not work
|
||||
// if dialog is opened from Angular code, but works fine if open dialog from React code)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
function handleNameChange(event) {
|
||||
const value = trim(event.target.value);
|
||||
setName(value);
|
||||
setIsValid(value !== '');
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (name !== '') {
|
||||
setSaveInProgress(true);
|
||||
|
||||
$http.post('api/dashboards', { name })
|
||||
.then(({ data }) => {
|
||||
dialog.close();
|
||||
$location.path(`/dashboard/${data.slug}`).search('edit').replace();
|
||||
});
|
||||
recordEvent('create', 'dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
{...(isCreateDashboardEnabled ? {} : { footer: null })}
|
||||
title="New Dashboard"
|
||||
okText="Save"
|
||||
cancelText="Close"
|
||||
okButtonProps={{
|
||||
disabled: !isValid || saveInProgress,
|
||||
loading: saveInProgress,
|
||||
'data-test': 'DashboardSaveButton',
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
disabled: saveInProgress,
|
||||
}}
|
||||
onOk={save}
|
||||
closable={!saveInProgress}
|
||||
maskClosable={!saveInProgress}
|
||||
wrapProps={{
|
||||
'data-test': 'CreateDashboardDialog',
|
||||
}}
|
||||
>
|
||||
<DynamicComponent name="CreateDashboardDialogExtra" disabled={!isCreateDashboardEnabled}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
defaultValue={name}
|
||||
onChange={handleNameChange}
|
||||
onPressEnter={save}
|
||||
placeholder="Dashboard Name"
|
||||
disabled={saveInProgress}
|
||||
/>
|
||||
</DynamicComponent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
CreateDashboardDialog.propTypes = {
|
||||
dialog: DialogPropType.isRequired,
|
||||
};
|
||||
|
||||
export default wrapDialog(CreateDashboardDialog);
|
||||
216
client/app/components/dashboards/DashboardGrid.jsx
Normal file
216
client/app/components/dashboards/DashboardGrid.jsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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 { FiltersType } from '@/components/Filters';
|
||||
import cfg from '@/config/dashboard-grid-options';
|
||||
import AutoHeightController from './AutoHeightController';
|
||||
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import './dashboard-grid.less';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
const WidgetType = PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
options: PropTypes.shape({
|
||||
position: PropTypes.shape({
|
||||
col: PropTypes.number.isRequired,
|
||||
row: PropTypes.number.isRequired,
|
||||
sizeY: PropTypes.number.isRequired,
|
||||
minSizeY: PropTypes.number.isRequired,
|
||||
maxSizeY: PropTypes.number.isRequired,
|
||||
sizeX: PropTypes.number.isRequired,
|
||||
minSizeX: PropTypes.number.isRequired,
|
||||
maxSizeX: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
});
|
||||
|
||||
const SINGLE = 'single-column';
|
||||
const MULTI = 'multi-column';
|
||||
|
||||
class DashboardGrid extends React.Component {
|
||||
static propTypes = {
|
||||
isEditing: PropTypes.bool.isRequired,
|
||||
isPublic: PropTypes.bool,
|
||||
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
widgets: PropTypes.arrayOf(WidgetType).isRequired,
|
||||
filters: FiltersType,
|
||||
onBreakpointChange: PropTypes.func,
|
||||
onRemoveWidget: PropTypes.func,
|
||||
onLayoutChange: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
isPublic: false,
|
||||
filters: [],
|
||||
onRemoveWidget: () => {},
|
||||
onLayoutChange: () => {},
|
||||
onBreakpointChange: () => {},
|
||||
};
|
||||
|
||||
static normalizeFrom(widget) {
|
||||
const { id, options: { position: pos } } = widget;
|
||||
|
||||
return {
|
||||
i: id.toString(),
|
||||
x: pos.col,
|
||||
y: pos.row,
|
||||
w: pos.sizeX,
|
||||
h: pos.sizeY,
|
||||
minW: pos.minSizeX,
|
||||
maxW: pos.maxSizeX,
|
||||
minH: pos.minSizeY,
|
||||
maxH: pos.maxSizeY,
|
||||
};
|
||||
}
|
||||
|
||||
mode = null;
|
||||
|
||||
autoHeightCtrl = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
layouts: {},
|
||||
disableAnimations: true,
|
||||
};
|
||||
|
||||
// init AutoHeightController
|
||||
this.autoHeightCtrl = new AutoHeightController(this.onWidgetHeightUpdated);
|
||||
this.autoHeightCtrl.update(this.props.widgets);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.onBreakpointChange(document.body.offsetWidth <= cfg.mobileBreakPoint ? SINGLE : MULTI);
|
||||
// Work-around to disable initial animation on widgets; `measureBeforeMount` doesn't work properly:
|
||||
// it disables animation, but it cannot detect scrollbars.
|
||||
setTimeout(() => {
|
||||
this.setState({ disableAnimations: false });
|
||||
}, 50);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
// update, in case widgets added or removed
|
||||
this.autoHeightCtrl.update(this.props.widgets);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.autoHeightCtrl.destroy();
|
||||
}
|
||||
|
||||
onLayoutChange = (_, layouts) => {
|
||||
// workaround for when dashboard starts at single mode and then multi is empty or carries single col data
|
||||
// fixes test dashboard_spec['shows widgets with full width']
|
||||
// TODO: open react-grid-layout issue
|
||||
if (layouts[MULTI]) {
|
||||
this.setState({ layouts });
|
||||
}
|
||||
|
||||
// workaround for https://github.com/STRML/react-grid-layout/issues/889
|
||||
// remove next line when fix lands
|
||||
this.mode = document.body.offsetWidth <= cfg.mobileBreakPoint ? SINGLE : MULTI;
|
||||
// end workaround
|
||||
|
||||
// don't save single column mode layout
|
||||
if (this.mode === SINGLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = chain(layouts[MULTI])
|
||||
.keyBy('i')
|
||||
.mapValues(this.normalizeTo)
|
||||
.value();
|
||||
|
||||
this.props.onLayoutChange(normalized);
|
||||
};
|
||||
|
||||
onBreakpointChange = (mode) => {
|
||||
this.mode = mode;
|
||||
this.props.onBreakpointChange(mode === SINGLE);
|
||||
};
|
||||
|
||||
// height updated by auto-height
|
||||
onWidgetHeightUpdated = (widgetId, newHeight) => {
|
||||
this.setState(({ layouts }) => {
|
||||
const layout = cloneDeep(layouts[MULTI]); // must clone to allow react-grid-layout to compare prev/next state
|
||||
const item = find(layout, { i: widgetId.toString() });
|
||||
if (item) {
|
||||
// update widget height
|
||||
item.h = Math.ceil((newHeight + cfg.margins) / cfg.rowHeight);
|
||||
}
|
||||
|
||||
return { layouts: { [MULTI]: layout } };
|
||||
});
|
||||
};
|
||||
|
||||
// height updated by manual resize
|
||||
onWidgetResize = (layout, oldItem, newItem) => {
|
||||
if (oldItem.h !== newItem.h) {
|
||||
this.autoHeightCtrl.remove(Number(newItem.i));
|
||||
}
|
||||
|
||||
this.autoHeightCtrl.resume();
|
||||
};
|
||||
|
||||
normalizeTo = layout => ({
|
||||
col: layout.x,
|
||||
row: layout.y,
|
||||
sizeX: layout.w,
|
||||
sizeY: layout.h,
|
||||
autoHeight: this.autoHeightCtrl.exists(layout.i),
|
||||
});
|
||||
|
||||
render() {
|
||||
const className = cx('dashboard-wrapper', this.props.isEditing ? 'editing-mode' : 'preview-mode');
|
||||
const { onRemoveWidget, dashboard, widgets } = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveGridLayout
|
||||
className={cx('layout', { 'disable-animations': this.state.disableAnimations })}
|
||||
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
|
||||
rowHeight={cfg.rowHeight - cfg.margins}
|
||||
margin={[cfg.margins, cfg.margins]}
|
||||
isDraggable={this.props.isEditing}
|
||||
isResizable={this.props.isEditing}
|
||||
onResizeStart={this.autoHeightCtrl.stop}
|
||||
onResizeStop={this.onWidgetResize}
|
||||
layouts={this.state.layouts}
|
||||
onLayoutChange={this.onLayoutChange}
|
||||
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>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dashboardGrid', react2angular(DashboardGrid));
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -6,6 +6,7 @@ import Modal from 'antd/lib/modal';
|
||||
import Input from 'antd/lib/input';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import Divider from 'antd/lib/divider';
|
||||
import HtmlContent from '@/components/HtmlContent';
|
||||
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
|
||||
import notification from '@/services/notification';
|
||||
|
||||
@@ -100,10 +101,7 @@ class TextboxDialog extends React.Component {
|
||||
<React.Fragment>
|
||||
<Divider dashed />
|
||||
<strong className="preview-title">Preview:</strong>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{ __html: this.state.preview }} // eslint-disable-line react/no-danger
|
||||
className="preview markdown"
|
||||
/>
|
||||
<HtmlContent className="preview markdown">{this.state.preview}</HtmlContent>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
7
client/app/components/dashboards/dashboard-grid.less
Normal file
7
client/app/components/dashboards/dashboard-grid.less
Normal file
@@ -0,0 +1,7 @@
|
||||
.react-grid-layout {
|
||||
&.disable-animations {
|
||||
& > .react-grid-item {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<div data-test="EditDashboardDialog">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" ng-click="$ctrl.dismiss()" ng-disabled="$ctrl.saveInProgress" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">New Dashboard</h4>
|
||||
</div>
|
||||
<div class="modal-body" ng-if="$ctrl.policy.isCreateDashboardEnabled()">
|
||||
<p>
|
||||
<input type="text" class="form-control" placeholder="Dashboard Name" ng-model="$ctrl.dashboard.name" autofocus ng-keyup="$event.keyCode === 13 && $ctrl.saveDashboard()">
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer" ng-if="$ctrl.policy.isCreateDashboardEnabled()">
|
||||
<button type="button" class="btn btn-default" ng-disabled="$ctrl.saveInProgress" ng-click="$ctrl.dismiss()">Close</button>
|
||||
<button type="button" class="btn btn-primary" ng-disabled="$ctrl.saveInProgress || !$ctrl.isFormValid()" ng-click="$ctrl.saveDashboard()" data-test="DashboardSaveButton">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" ng-if="!$ctrl.policy.isCreateDashboardEnabled()">
|
||||
<edit-dashboard-dialog-disabled></edit-dashboard-dialog-disabled>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,47 +0,0 @@
|
||||
import { isEmpty } from 'lodash';
|
||||
import { policy } from '@/services/policy';
|
||||
import template from './edit-dashboard-dialog.html';
|
||||
|
||||
const EditDashboardDialog = {
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
template,
|
||||
controller($location, $http, Events) {
|
||||
'ngInject';
|
||||
|
||||
this.dashboard = this.resolve.dashboard;
|
||||
this.policy = policy;
|
||||
|
||||
this.isFormValid = () => !isEmpty(this.dashboard.name);
|
||||
|
||||
this.saveDashboard = () => {
|
||||
if (!this.isFormValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saveInProgress = true;
|
||||
|
||||
$http
|
||||
.post('api/dashboards', {
|
||||
name: this.dashboard.name,
|
||||
})
|
||||
.success((response) => {
|
||||
this.close();
|
||||
$location
|
||||
.path(`/dashboard/${response.slug}`)
|
||||
.search('edit')
|
||||
.replace();
|
||||
});
|
||||
Events.record('create', 'dashboard');
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editDashboardDialog', EditDashboardDialog);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -1,87 +0,0 @@
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import 'jquery-ui/ui/widgets/draggable';
|
||||
import 'jquery-ui/ui/widgets/droppable';
|
||||
import 'jquery-ui/ui/widgets/resizable';
|
||||
import 'gridstack/dist/gridstack.css';
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import gridstack from 'gridstack';
|
||||
|
||||
function sequence(...fns) {
|
||||
fns = _.filter(fns, _.isFunction);
|
||||
if (fns.length > 0) {
|
||||
return function sequenceWrapper(...args) {
|
||||
for (let i = 0; i < fns.length; i += 1) {
|
||||
fns[i].apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
return _.noop;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
function JQueryUIGridStackDragDropPlugin(grid) {
|
||||
gridstack.GridStackDragDropPlugin.call(this, grid);
|
||||
}
|
||||
|
||||
gridstack.GridStackDragDropPlugin.registerPlugin(JQueryUIGridStackDragDropPlugin);
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype = Object.create(gridstack.GridStackDragDropPlugin.prototype);
|
||||
JQueryUIGridStackDragDropPlugin.prototype.constructor = JQueryUIGridStackDragDropPlugin;
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.resizable = function resizable(el, opts, key, value) {
|
||||
el = $(el);
|
||||
if (opts === 'disable' || opts === 'enable') {
|
||||
el.resizable(opts);
|
||||
} else if (opts === 'option') {
|
||||
el.resizable(opts, key, value);
|
||||
} else {
|
||||
el.resizable(_.extend({}, this.grid.opts.resizable, {
|
||||
// run user-defined callback before internal one
|
||||
start: sequence(this.grid.opts.resizable.start, opts.start),
|
||||
// this and next - run user-defined callback after internal one
|
||||
stop: sequence(opts.stop, this.grid.opts.resizable.stop),
|
||||
resize: sequence(opts.resize, this.grid.opts.resizable.resize),
|
||||
}));
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.draggable = function draggable(el, opts) {
|
||||
el = $(el);
|
||||
if (opts === 'disable' || opts === 'enable') {
|
||||
el.draggable(opts);
|
||||
} else {
|
||||
el.draggable(_.extend({}, this.grid.opts.draggable, {
|
||||
containment: this.grid.opts.isNested ? this.grid.container.parent() : null,
|
||||
// run user-defined callback before internal one
|
||||
start: sequence(this.grid.opts.draggable.start, opts.start),
|
||||
// this and next - run user-defined callback after internal one
|
||||
stop: sequence(opts.stop, this.grid.opts.draggable.stop),
|
||||
drag: sequence(opts.drag, this.grid.opts.draggable.drag),
|
||||
}));
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.droppable = function droppable(el, opts) {
|
||||
el = $(el);
|
||||
if (opts === 'disable' || opts === 'enable') {
|
||||
el.droppable(opts);
|
||||
} else {
|
||||
el.droppable({
|
||||
accept: opts.accept,
|
||||
});
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.isDroppable = function isDroppable(el) {
|
||||
return Boolean($(el).data('droppable'));
|
||||
};
|
||||
|
||||
JQueryUIGridStackDragDropPlugin.prototype.on = function on(el, eventName, callback) {
|
||||
$(el).on(eventName, callback);
|
||||
return this;
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
.grid-stack {
|
||||
// Same options as in JS
|
||||
@gridstack-margin: 15px;
|
||||
@gridstack-width: 6;
|
||||
|
||||
margin-right: -@gridstack-margin;
|
||||
|
||||
.gridstack-columns(@column, @total) when (@column > 0) {
|
||||
@value: 100% * (@column / @total);
|
||||
> .grid-stack-item[data-gs-min-width="@{column}"] { min-width: @value }
|
||||
> .grid-stack-item[data-gs-max-width="@{column}"] { max-width: @value }
|
||||
> .grid-stack-item[data-gs-width="@{column}"] { width: @value }
|
||||
> .grid-stack-item[data-gs-x="@{column}"] { left: @value }
|
||||
|
||||
.gridstack-columns((@column - 1), @total); // next iteration
|
||||
}
|
||||
|
||||
.gridstack-columns(@gridstack-width, @gridstack-width);
|
||||
|
||||
.grid-stack-item {
|
||||
.grid-stack-item-content {
|
||||
overflow: visible !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 1 !important;
|
||||
left: 0 !important;
|
||||
right: @gridstack-margin !important;
|
||||
}
|
||||
|
||||
.ui-resizable-handle {
|
||||
background: none !important;
|
||||
|
||||
&.ui-resizable-w,
|
||||
&.ui-resizable-sw {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
&.ui-resizable-e,
|
||||
&.ui-resizable-se {
|
||||
right: @gridstack-margin !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.grid-stack-placeholder > .placeholder-content {
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 3px;
|
||||
left: 0 !important;
|
||||
right: @gridstack-margin !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.grid-stack-one-column-mode > .grid-stack-item {
|
||||
margin-bottom: @gridstack-margin !important;
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import './gridstack';
|
||||
import './gridstack.less';
|
||||
|
||||
function toggleAutoHeightClass($element, isEnabled) {
|
||||
const className = 'widget-auto-height-enabled';
|
||||
if (isEnabled) {
|
||||
$element.addClass(className);
|
||||
} else {
|
||||
$element.removeClass(className);
|
||||
}
|
||||
}
|
||||
|
||||
function computeAutoHeight($element, grid, node, minHeight, maxHeight) {
|
||||
const wrapper = $element[0];
|
||||
const element = wrapper.querySelector('.scrollbox, .spinner-container');
|
||||
|
||||
let resultHeight = _.isObject(node) ? node.height : 1;
|
||||
if (element) {
|
||||
const childrenBounds = _.chain(element.children)
|
||||
.map((child) => {
|
||||
const bounds = child.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(child);
|
||||
return {
|
||||
top: bounds.top - parseFloat(style.marginTop),
|
||||
bottom: bounds.bottom + parseFloat(style.marginBottom),
|
||||
};
|
||||
})
|
||||
.reduce((result, bounds) => ({
|
||||
top: Math.min(result.top, bounds.top),
|
||||
bottom: Math.max(result.bottom, bounds.bottom),
|
||||
}))
|
||||
.value() || { top: 0, bottom: 0 };
|
||||
|
||||
// Height of controls outside visualization area
|
||||
const bodyWrapper = wrapper.querySelector('.body-container');
|
||||
if (bodyWrapper) {
|
||||
const elementStyle = window.getComputedStyle(element);
|
||||
const controlsHeight = _.chain(bodyWrapper.children)
|
||||
.filter(n => n !== element)
|
||||
.reduce((result, n) => {
|
||||
const b = n.getBoundingClientRect();
|
||||
return result + (b.bottom - b.top);
|
||||
}, 0)
|
||||
.value();
|
||||
|
||||
const additionalHeight = grid.opts.verticalMargin +
|
||||
// include container paddings too
|
||||
parseFloat(elementStyle.paddingTop) + parseFloat(elementStyle.paddingBottom) +
|
||||
// add few pixels for scrollbar (if visible)
|
||||
(element.scrollWidth > element.offsetWidth ? 16 : 0);
|
||||
|
||||
const contentsHeight = childrenBounds.bottom - childrenBounds.top;
|
||||
|
||||
const cellHeight = grid.cellHeight() + grid.opts.verticalMargin;
|
||||
resultHeight = Math.ceil(Math.round(controlsHeight + contentsHeight + additionalHeight) / cellHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// minHeight <= resultHeight <= maxHeight
|
||||
return Math.min(Math.max(minHeight, resultHeight), maxHeight);
|
||||
}
|
||||
|
||||
function gridstack($parse, dashboardGridOptions) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
replace: false,
|
||||
scope: {
|
||||
editing: '=',
|
||||
batchUpdate: '=', // set by directive - for using in wrapper components
|
||||
onLayoutChanged: '=',
|
||||
isOneColumnMode: '=',
|
||||
},
|
||||
controller() {
|
||||
this.$el = null;
|
||||
|
||||
this.resizingWidget = null;
|
||||
this.draggingWidget = null;
|
||||
|
||||
this.grid = () => (this.$el ? this.$el.data('gridstack') : null);
|
||||
|
||||
this._updateStyles = () => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
// compute real grid height; `gridstack` sometimes uses only "dirty"
|
||||
// items and computes wrong height
|
||||
const gridHeight = _.chain(grid.grid.nodes)
|
||||
.map(node => node.y + node.height)
|
||||
.max()
|
||||
.value();
|
||||
// `_updateStyles` is internal, but grid sometimes "forgets"
|
||||
// to rebuild stylesheet, so we need to force it
|
||||
if (_.isObject(grid._styles)) {
|
||||
grid._styles._max = 0; // reset size cache
|
||||
}
|
||||
grid._updateStyles(gridHeight + 10);
|
||||
}
|
||||
};
|
||||
|
||||
this.addWidget = ($element, item, itemId) => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
grid.addWidget(
|
||||
$element,
|
||||
item.col, item.row, item.sizeX, item.sizeY,
|
||||
false, // auto position
|
||||
item.minSizeX, item.maxSizeX, item.minSizeY, item.maxSizeY,
|
||||
itemId,
|
||||
);
|
||||
this._updateStyles();
|
||||
}
|
||||
};
|
||||
|
||||
this.updateWidget = ($element, item) => {
|
||||
this.update((grid) => {
|
||||
grid.update($element, item.col, item.row, item.sizeX, item.sizeY);
|
||||
grid.minWidth($element, item.minSizeX);
|
||||
grid.maxWidth($element, item.maxSizeX);
|
||||
grid.minHeight($element, item.minSizeY);
|
||||
grid.maxHeight($element, item.maxSizeY);
|
||||
});
|
||||
};
|
||||
|
||||
this.removeWidget = ($element) => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
grid.removeWidget($element, false);
|
||||
this._updateStyles();
|
||||
}
|
||||
};
|
||||
|
||||
this.getNodeByElement = (element) => {
|
||||
const grid = this.grid();
|
||||
if (grid && grid.grid) {
|
||||
// This method seems to be internal
|
||||
return grid.grid.getNodeDataByDOMEl($(element));
|
||||
}
|
||||
};
|
||||
|
||||
this.setWidgetId = ($element, id) => {
|
||||
// `gridstack` has no API method to change node id; but since it's not used
|
||||
// by library, we can just update grid and DOM node
|
||||
const node = this.getNodeByElement($element);
|
||||
if (node) {
|
||||
node.id = id;
|
||||
$element.attr('data-gs-id', _.isUndefined(id) ? null : id);
|
||||
}
|
||||
};
|
||||
|
||||
this.setEditing = (value) => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
if (value) {
|
||||
grid.enable();
|
||||
} else {
|
||||
grid.disable();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.update = (callback) => {
|
||||
const grid = this.grid();
|
||||
if (grid) {
|
||||
grid.batchUpdate();
|
||||
try {
|
||||
if (_.isFunction(callback)) {
|
||||
callback(grid);
|
||||
}
|
||||
} finally {
|
||||
grid.commit();
|
||||
this._updateStyles();
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
link: ($scope, $element, $attr, controller) => {
|
||||
const isOneColumnModeAssignable = _.isFunction($parse($attr.onLayoutChanged).assign);
|
||||
let enablePolling = true;
|
||||
|
||||
$element.addClass('grid-stack');
|
||||
$element.gridstack({
|
||||
auto: false,
|
||||
verticalMargin: dashboardGridOptions.margins,
|
||||
// real row height will be `cellHeight` + `verticalMargin`
|
||||
cellHeight: dashboardGridOptions.rowHeight - dashboardGridOptions.margins,
|
||||
width: dashboardGridOptions.columns, // columns
|
||||
height: 0, // max rows (0 for unlimited)
|
||||
animate: true,
|
||||
float: false,
|
||||
minWidth: dashboardGridOptions.mobileBreakPoint,
|
||||
resizable: {
|
||||
handles: 'e, se, s, sw, w',
|
||||
start: (event, ui) => {
|
||||
controller.resizingWidget = ui.element;
|
||||
$(ui.element).trigger(
|
||||
'gridstack.resize-start',
|
||||
controller.getNodeByElement(ui.element),
|
||||
);
|
||||
},
|
||||
stop: (event, ui) => {
|
||||
controller.resizingWidget = null;
|
||||
$(ui.element).trigger(
|
||||
'gridstack.resize-end',
|
||||
controller.getNodeByElement(ui.element),
|
||||
);
|
||||
controller.update();
|
||||
},
|
||||
},
|
||||
draggable: {
|
||||
start: (event, ui) => {
|
||||
controller.draggingWidget = ui.helper;
|
||||
$(ui.helper).trigger(
|
||||
'gridstack.drag-start',
|
||||
controller.getNodeByElement(ui.helper),
|
||||
);
|
||||
},
|
||||
stop: (event, ui) => {
|
||||
controller.draggingWidget = null;
|
||||
$(ui.helper).trigger(
|
||||
'gridstack.drag-end',
|
||||
controller.getNodeByElement(ui.helper),
|
||||
);
|
||||
controller.update();
|
||||
},
|
||||
},
|
||||
});
|
||||
controller.$el = $element;
|
||||
|
||||
// `change` events sometimes fire too frequently (for example,
|
||||
// on initial rendering when all widgets add themselves to grid, grid
|
||||
// will fire `change` event will _all_ items available at that moment).
|
||||
// Collect changed items, and then delegate event with some delay
|
||||
let changedNodes = {};
|
||||
const triggerChange = _.debounce(() => {
|
||||
_.each(changedNodes, (node) => {
|
||||
if (node.el) {
|
||||
$(node.el).trigger('gridstack.changed', node);
|
||||
}
|
||||
});
|
||||
if ($scope.onLayoutChanged) {
|
||||
$scope.onLayoutChanged();
|
||||
}
|
||||
changedNodes = {};
|
||||
});
|
||||
|
||||
$element.on('change', (event, nodes) => {
|
||||
nodes = _.isArray(nodes) ? nodes : [];
|
||||
_.each(nodes, (node) => {
|
||||
changedNodes[node.id] = node;
|
||||
});
|
||||
triggerChange();
|
||||
});
|
||||
|
||||
$scope.$watch('editing', (value) => {
|
||||
controller.setEditing(!!value);
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
enablePolling = false;
|
||||
controller.$el = null;
|
||||
});
|
||||
|
||||
// `gridstack` does not provide API to detect when one-column mode changes.
|
||||
// Just watch `$element` for specific class
|
||||
function updateOneColumnMode() {
|
||||
const grid = controller.grid();
|
||||
if (grid) {
|
||||
const isOneColumnMode = $element.hasClass(grid.opts.oneColumnModeClass);
|
||||
if ($scope.isOneColumnMode !== isOneColumnMode) {
|
||||
$scope.isOneColumnMode = isOneColumnMode;
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (enablePolling) {
|
||||
setTimeout(updateOneColumnMode, 150);
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling only if we can update scope binding; otherwise it
|
||||
// will just waisting CPU time (example: public dashboards don't need it)
|
||||
if (isOneColumnModeAssignable) {
|
||||
updateOneColumnMode();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gridstackItem($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
replace: false,
|
||||
require: '^gridstack',
|
||||
scope: {
|
||||
gridstackItem: '=',
|
||||
gridstackItemId: '@',
|
||||
},
|
||||
link: ($scope, $element, $attr, controller) => {
|
||||
let enablePolling = true;
|
||||
let heightBeforeResize = null;
|
||||
|
||||
controller.addWidget($element, $scope.gridstackItem, $scope.gridstackItemId);
|
||||
|
||||
// these events are triggered only on user interaction
|
||||
$element.on('gridstack.resize-start', () => {
|
||||
const node = controller.getNodeByElement($element);
|
||||
heightBeforeResize = _.isObject(node) ? node.height : null;
|
||||
});
|
||||
$element.on('gridstack.resize-end', (event, node) => {
|
||||
const item = $scope.gridstackItem;
|
||||
if (
|
||||
_.isObject(node) && _.isObject(item) &&
|
||||
(node.height !== heightBeforeResize) &&
|
||||
(heightBeforeResize !== null)
|
||||
) {
|
||||
item.autoHeight = false;
|
||||
toggleAutoHeightClass($element, item.autoHeight);
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
});
|
||||
|
||||
$element.on('gridstack.changed', (event, node) => {
|
||||
const item = $scope.gridstackItem;
|
||||
if (_.isObject(node) && _.isObject(item)) {
|
||||
let dirty = false;
|
||||
if (node.x !== item.col) {
|
||||
item.col = node.x;
|
||||
dirty = true;
|
||||
}
|
||||
if (node.y !== item.row) {
|
||||
item.row = node.y;
|
||||
dirty = true;
|
||||
}
|
||||
if (node.width !== item.sizeX) {
|
||||
item.sizeX = node.width;
|
||||
dirty = true;
|
||||
}
|
||||
if (node.height !== item.sizeY) {
|
||||
item.sizeY = node.height;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('gridstackItem.autoHeight', () => {
|
||||
const item = $scope.gridstackItem;
|
||||
if (_.isObject(item)) {
|
||||
toggleAutoHeightClass($element, item.autoHeight);
|
||||
} else {
|
||||
toggleAutoHeightClass($element, false);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('gridstackItemId', () => {
|
||||
controller.setWidgetId($element, $scope.gridstackItemId);
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
enablePolling = false;
|
||||
$timeout(() => {
|
||||
controller.removeWidget($element);
|
||||
});
|
||||
});
|
||||
|
||||
function update() {
|
||||
if (!controller.resizingWidget && !controller.draggingWidget) {
|
||||
const item = $scope.gridstackItem;
|
||||
const grid = controller.grid();
|
||||
if (grid && _.isObject(item) && item.autoHeight) {
|
||||
const sizeY = computeAutoHeight(
|
||||
$element, grid, controller.getNodeByElement($element),
|
||||
item.minSizeY, item.maxSizeY,
|
||||
);
|
||||
if (sizeY !== item.sizeY) {
|
||||
item.sizeY = sizeY;
|
||||
controller.updateWidget($element, { sizeY });
|
||||
$scope.$applyAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enablePolling) {
|
||||
setTimeout(update, 150);
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('gridstack', gridstack);
|
||||
ngModule.directive('gridstackItem', gridstackItem);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.widget.getQueryResult()" class="t-body"></visualization-renderer>
|
||||
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.widget.getQueryResult()" class="t-body" context="'widget'"></visualization-renderer>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" ng-click="$ctrl.dismiss()">Close</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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()">
|
||||
<div class="body-row">
|
||||
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">
|
||||
@@ -12,7 +12,7 @@
|
||||
uib-dropdown dropdown-append-to-body="true"
|
||||
>
|
||||
<div class="actions">
|
||||
<a data-toggle="dropdown" uib-dropdown-toggle><i class="zmdi zmdi-more-vert"></i></a>
|
||||
<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>
|
||||
@@ -29,6 +29,12 @@
|
||||
<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"
|
||||
@@ -38,7 +44,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-b-10" ng-if="$ctrl.localParametersDefs().length > 0">
|
||||
<parameters parameters="$ctrl.localParametersDefs()"></parameters>
|
||||
<parameters parameters="$ctrl.localParametersDefs()" on-values-change="$ctrl.forceRefresh"></parameters>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +56,7 @@
|
||||
visualization="$ctrl.widget.visualization"
|
||||
query-result="$ctrl.widget.getQueryResult()"
|
||||
filters="$ctrl.filters"
|
||||
context="'widget'"
|
||||
></visualization-renderer>
|
||||
</div>
|
||||
<div ng-switch-default class="body-row-auto spinner-container">
|
||||
@@ -58,11 +65,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body-row clearfix tile__bottom-control">
|
||||
<a class="small hidden-print" ng-click="$ctrl.refresh()" ng-if="!$ctrl.public" data-test="RefreshIndicator">
|
||||
<i ng-class='{"zmdi-hc-spin": $ctrl.widget.loading}' class="zmdi zmdi-refresh"></i>
|
||||
<span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()" ng-if="!$ctrl.widget.loading"></span>
|
||||
<rd-timer from="$ctrl.widget.refreshStartedAt" ng-if="$ctrl.widget.loading"></rd-timer>
|
||||
<div class="body-row tile__bottom-control">
|
||||
<span>
|
||||
<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>
|
||||
@@ -70,14 +77,19 @@
|
||||
<span class="visible-print">
|
||||
<i class="zmdi zmdi-time-restore"></i> {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.refresh()" ng-if="!$ctrl.public"><i class="zmdi zmdi-refresh"></i></button>
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.expandVisualization()"><i class="zmdi zmdi-fullscreen"></i></button>
|
||||
<span>
|
||||
<button class="btn btn-sm btn-default hidden-print btn-transparent btn__refresh" ng-click="$ctrl.expandVisualization()"><i class="zmdi zmdi-fullscreen"></i></button>
|
||||
<button class="btn btn-sm btn-default 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>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tile body-container widget-restricted" ng-if="$ctrl.type=='restricted'" ng-class="$ctrl.type">
|
||||
<div class="t-body body-row-auto scrollbox">
|
||||
<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">
|
||||
@@ -94,12 +106,13 @@
|
||||
<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>
|
||||
<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"><i class="zmdi zmdi-more"></i></a>
|
||||
<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 pull-right" uib-dropdown-menu style="z-index:1000000">
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -18,6 +19,8 @@ const WidgetDialog = {
|
||||
},
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
@@ -86,11 +89,16 @@ function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope,
|
||||
|
||||
this.load = (refresh = false) => {
|
||||
const maxAge = $location.search().maxAge;
|
||||
this.widget.load(refresh, maxAge);
|
||||
return this.widget.load(refresh, maxAge);
|
||||
};
|
||||
|
||||
this.refresh = () => {
|
||||
this.load(true);
|
||||
this.forceRefresh = () => this.load(true);
|
||||
|
||||
this.refresh = (buttonId) => {
|
||||
this.refreshClickButtonId = buttonId;
|
||||
this.load(true).finally(() => {
|
||||
this.refreshClickButtonId = undefined;
|
||||
});
|
||||
};
|
||||
|
||||
if (this.widget.visualization) {
|
||||
@@ -106,9 +114,7 @@ function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope,
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('widgetDialog', WidgetDialog);
|
||||
ngModule.component('dashboardWidget', {
|
||||
const DashboardWidgetOptions = {
|
||||
template,
|
||||
controller: DashboardWidgetCtrl,
|
||||
bindings: {
|
||||
@@ -116,9 +122,16 @@ export default function init(ngModule) {
|
||||
public: '<',
|
||||
dashboard: '<',
|
||||
filters: '<',
|
||||
deleted: '&onDelete',
|
||||
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,3 +1,5 @@
|
||||
@import '../../assets/less/inc/variables';
|
||||
|
||||
.tile .t-header .th-title a.query-link {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@@ -26,6 +28,10 @@ visualization-name {
|
||||
}
|
||||
|
||||
.widget-wrapper {
|
||||
.parameter-container {
|
||||
margin: 0 15px;
|
||||
}
|
||||
|
||||
.body-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -89,3 +95,208 @@ visualization-name {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import Icon from 'antd/lib/icon';
|
||||
import { includes, isFunction } from 'lodash';
|
||||
import Select from 'antd/lib/select';
|
||||
import notification from '@/services/notification';
|
||||
import AceEditorInput from '@/components/AceEditorInput';
|
||||
import { Field, Action, AntdForm } from '../proptypes';
|
||||
import helper from './dynamicFormHelper';
|
||||
|
||||
@@ -94,7 +95,7 @@ class DynamicForm extends React.Component {
|
||||
);
|
||||
} else this.setState({ isSubmitting: false });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleAction = (e) => {
|
||||
const actionName = e.target.dataset.action;
|
||||
@@ -103,7 +104,7 @@ class DynamicForm extends React.Component {
|
||||
this.actionCallbacks[actionName](() => {
|
||||
this.setActionInProgress(actionName, false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
base64File = (fieldName, e) => {
|
||||
if (e && e.fileList[0]) {
|
||||
@@ -111,7 +112,7 @@ class DynamicForm extends React.Component {
|
||||
this.props.form.setFieldsValue({ [fieldName]: value });
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderUpload(field, props) {
|
||||
const { getFieldDecorator, getFieldValue } = this.props.form;
|
||||
@@ -174,6 +175,10 @@ class DynamicForm extends React.Component {
|
||||
return field.content;
|
||||
} else if (type === 'number') {
|
||||
return getFieldDecorator(name, options)(<InputNumber {...props} />);
|
||||
} else if (type === 'textarea') {
|
||||
return getFieldDecorator(name, options)(<Input.TextArea {...props} />);
|
||||
} else if (type === 'ace') {
|
||||
return getFieldDecorator(name, options)(<AceEditorInput {...props} />);
|
||||
}
|
||||
return getFieldDecorator(name, options)(<Input {...props} />);
|
||||
}
|
||||
|
||||
104
client/app/components/dynamic-parameters/DateParameter.jsx
Normal file
104
client/app/components/dynamic-parameters/DateParameter.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import { includes } from 'lodash';
|
||||
import { isDynamicDate, getDynamicDate } from '@/services/query';
|
||||
import DateInput from '@/components/DateInput';
|
||||
import DateTimeInput from '@/components/DateTimeInput';
|
||||
import DynamicButton from '@/components/dynamic-parameters/DynamicButton';
|
||||
|
||||
import './DynamicParameters.less';
|
||||
|
||||
const DYNAMIC_DATE_OPTIONS = [
|
||||
{ name: 'Today/Now',
|
||||
value: 'd_now',
|
||||
label: () => getDynamicDate('d_now').value().format('MMM D') },
|
||||
{ name: 'Yesterday',
|
||||
value: 'd_yesterday',
|
||||
label: () => getDynamicDate('d_yesterday').value().format('MMM D') },
|
||||
];
|
||||
|
||||
class DateParameter extends React.Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
type: '',
|
||||
className: '',
|
||||
value: null,
|
||||
parameter: null,
|
||||
onSelect: () => {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.dateComponentRef = React.createRef();
|
||||
}
|
||||
|
||||
onDynamicValueSelect = (dynamicValue) => {
|
||||
const { onSelect, parameter } = this.props;
|
||||
if (dynamicValue === 'static') {
|
||||
const parameterValue = parameter.getValue();
|
||||
if (parameterValue) {
|
||||
onSelect(moment(parameterValue));
|
||||
} else {
|
||||
onSelect(null);
|
||||
}
|
||||
} else {
|
||||
onSelect(dynamicValue.value);
|
||||
}
|
||||
// give focus to the DatePicker to get keyboard shortcuts to work
|
||||
this.dateComponentRef.current.focus();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type, value, className, onSelect } = this.props;
|
||||
const hasDynamicValue = isDynamicDate(value);
|
||||
const isDateTime = includes(type, 'datetime');
|
||||
|
||||
const additionalAttributes = {};
|
||||
|
||||
let DateComponent = DateInput;
|
||||
if (isDateTime) {
|
||||
DateComponent = DateTimeInput;
|
||||
if (includes(type, 'with-seconds')) {
|
||||
additionalAttributes.withSeconds = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (moment.isMoment(value) || value === null) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
|
||||
if (hasDynamicValue) {
|
||||
const dynamicDate = getDynamicDate(value);
|
||||
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
|
||||
additionalAttributes.value = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DateComponent
|
||||
ref={this.dateComponentRef}
|
||||
className={classNames('redash-datepicker', { 'dynamic-value': hasDynamicValue }, className)}
|
||||
onSelect={onSelect}
|
||||
suffixIcon={(
|
||||
<DynamicButton
|
||||
options={DYNAMIC_DATE_OPTIONS}
|
||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||
enabled={hasDynamicValue}
|
||||
onSelect={this.onDynamicValueSelect}
|
||||
/>
|
||||
)}
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DateParameter;
|
||||
135
client/app/components/dynamic-parameters/DateRangeParameter.jsx
Normal file
135
client/app/components/dynamic-parameters/DateRangeParameter.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
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 DateRangeInput from '@/components/DateRangeInput';
|
||||
import DateTimeRangeInput from '@/components/DateTimeRangeInput';
|
||||
import DynamicButton from '@/components/dynamic-parameters/DynamicButton';
|
||||
|
||||
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') },
|
||||
{ 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') },
|
||||
{ name: 'Last 7 days',
|
||||
value: 'd_last_7_days',
|
||||
label: () => getDynamicDateRange('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') },
|
||||
{ name: 'Yesterday',
|
||||
value: 'd_yesterday',
|
||||
label: () => getDynamicDateRange('d_yesterday').value()[0].format('MMM D') },
|
||||
...DYNAMIC_DATE_OPTIONS,
|
||||
];
|
||||
|
||||
const widthByType = {
|
||||
'date-range': 294,
|
||||
'datetime-range': 352,
|
||||
'datetime-range-with-seconds': 382,
|
||||
};
|
||||
|
||||
function isValidDateRangeValue(value) {
|
||||
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
|
||||
}
|
||||
|
||||
class DateRangeParameter extends React.Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
type: '',
|
||||
className: '',
|
||||
value: null,
|
||||
parameter: null,
|
||||
onSelect: () => {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.dateRangeComponentRef = React.createRef();
|
||||
}
|
||||
|
||||
onDynamicValueSelect = (dynamicValue) => {
|
||||
const { onSelect, parameter } = this.props;
|
||||
if (dynamicValue === 'static') {
|
||||
const parameterValue = parameter.getValue();
|
||||
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
|
||||
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
|
||||
} else {
|
||||
onSelect(null);
|
||||
}
|
||||
} else {
|
||||
onSelect(dynamicValue.value);
|
||||
}
|
||||
// give focus to the DatePicker to get keyboard shortcuts to work
|
||||
this.dateRangeComponentRef.current.focus();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type, value, onSelect, className } = this.props;
|
||||
const isDateTimeRange = includes(type, 'datetime-range');
|
||||
const hasDynamicValue = isDynamicDateRange(value);
|
||||
const options = isDateTimeRange ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
|
||||
|
||||
const additionalAttributes = {};
|
||||
|
||||
let DateRangeComponent = DateRangeInput;
|
||||
if (isDateTimeRange) {
|
||||
DateRangeComponent = DateTimeRangeInput;
|
||||
if (includes(type, 'with-seconds')) {
|
||||
additionalAttributes.withSeconds = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidDateRangeValue(value) || value === null) {
|
||||
additionalAttributes.value = value;
|
||||
}
|
||||
|
||||
if (hasDynamicValue) {
|
||||
const dynamicDateRange = getDynamicDateRange(value);
|
||||
additionalAttributes.placeholder = [dynamicDateRange && dynamicDateRange.name];
|
||||
additionalAttributes.value = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DateRangeComponent
|
||||
ref={this.dateRangeComponentRef}
|
||||
className={classNames('redash-datepicker date-range-input', { 'dynamic-value': hasDynamicValue }, className)}
|
||||
onSelect={onSelect}
|
||||
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
|
||||
suffixIcon={(
|
||||
<DynamicButton
|
||||
options={options}
|
||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||
enabled={hasDynamicValue}
|
||||
onSelect={this.onDynamicValueSelect}
|
||||
/>
|
||||
)}
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DateRangeParameter;
|
||||
77
client/app/components/dynamic-parameters/DynamicButton.jsx
Normal file
77
client/app/components/dynamic-parameters/DynamicButton.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isFunction, get, findIndex } from 'lodash';
|
||||
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 './DynamicButton.less';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
||||
const menu = (
|
||||
<Menu
|
||||
className="dynamic-menu"
|
||||
onClick={({ key }) => onSelect(get(options, key, 'static'))}
|
||||
selectedKeys={[`${findIndex(options, { value: selectedDynamicValue })}`]}
|
||||
data-test="DynamicButtonMenu"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Menu.Item key={index}>
|
||||
{option.name} {option.label && (
|
||||
<em>{isFunction(option.label) ? option.label() : option.label}</em>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{enabled && <Menu.Divider />}
|
||||
{enabled && (
|
||||
<Menu.Item>
|
||||
<Icon type="arrow-left" /><Text type="secondary">Back to Static Value</Text>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
const containerRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<a onClick={e => e.stopPropagation()}>
|
||||
<Dropdown.Button
|
||||
overlay={menu}
|
||||
className="dynamic-button"
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
icon={(
|
||||
<Icon
|
||||
type="thunderbolt"
|
||||
theme={enabled ? 'twoTone' : 'outlined'}
|
||||
className="dynamic-icon"
|
||||
/>
|
||||
)}
|
||||
getPopupContainer={() => containerRef.current}
|
||||
data-test="DynamicButton"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DynamicButton.propTypes = {
|
||||
options: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
|
||||
selectedDynamicValue: PropTypes.string,
|
||||
onSelect: PropTypes.func,
|
||||
enabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
DynamicButton.defaultProps = {
|
||||
options: [],
|
||||
selectedDynamicValue: null,
|
||||
onSelect: () => {},
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
export default DynamicButton;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user