Compare commits

...

213 Commits

Author SHA1 Message Date
Arik Fraimovich
a16f551e22 Pin Cypress version (#4284) 2019-10-27 15:02:03 +02:00
Arik Fraimovich
e94515d340 Updated package-lock.json file 2019-10-27 14:23:12 +02:00
Arik Fraimovich
8de1fa3318 Make the build-docker-image step take approval 2019-10-27 13:44:18 +02:00
Arik Fraimovich
6227a1d071 Remove beta tag 2019-10-27 13:43:10 +02:00
Arik Fraimovich
13b6bfc55f CHANGELOG for v8.0.0-beta.2 (#4145)
* Stop building tarballs.

* Update version reference.

* CHANGELOG for 8.0.0-beta.2
2019-10-27 13:42:37 +02:00
Levko Kravets
f5802d2dec Widget filters overlapped by visualization (#4137)
* Fix: widget filters overlapped by visualization

* Fix tests

* Fix tests
2019-10-27 13:42:37 +02:00
Levko Kravets
ba0ccebe58 Color picker component (#4136) 2019-10-27 13:42:37 +02:00
Gabriel Dutra
c5a65b3321 Query Snippets: Use onClick instead of link for 'Click here' option (#4144)
* Snippets: Don't change url when not needed

* Revert "Snippets: Don't change url when not needed"

This reverts commit 2f346f3bb4.

* Query Snippets: use onClick instead of link
2019-10-27 13:42:37 +02:00
Ran Byron
c622a76f3a Bug fix: Query view doesn't sync parameters when selecting and deleting (#4146) 2019-10-27 13:42:37 +02:00
Arik Fraimovich
76e0fa6e9c CHANGELOG for V8-beta. (#4057)
* CHANGELOG for V8-beta.

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2019-10-27 13:42:37 +02:00
Arik Fraimovich
f0ba045913 Allow users to share aggregated usage information with us (#4108)
* Initial commit of BeaconConsent component

* Add comment about being able to change setting

* Use <Text> correctly

* Final version of consent screen

* Show beacon consent message on homepage only if it wasn't enabled already.

* Add consent setting to organization settings screen.

* Add support for custom message in OrgSetting.save.

* Implmenet consent saving.

* If consent given, send extra data

* Add HelpTrigger

* Make CodeClimate happy

* Wrap everything with DynamicComponent
2019-10-27 13:42:37 +02:00
Levko Kravets
7bf4219e58 Counter Editor: move components to own files (#4138) 2019-10-27 13:42:37 +02:00
Gabriel Dutra
fe477aa855 Add jsconfig settings with '@' webpack alias (#4135) 2019-10-27 13:42:37 +02:00
Levko Kravets
da09de6def Migrate Chart visualization to React Part 1: Renderer (#4130)
* Migrate Chart visualization: Renderer

* Refine PlotlyChart component; move stylesheets to visualization's folder

* Migrate Custom JS Chart to React

* Cleanup
2019-10-27 13:42:37 +02:00
Arik Fraimovich
f252821400 Remove duplicate messages method (#4131) 2019-10-27 13:42:37 +02:00
Levko Kravets
2cdc88293d Alerts: Add more condition comparison options (#4134)
* getredash/redash#4132 Add more condition comparison options

* Add arguments to fallback lambda
2019-10-27 13:42:37 +02:00
Levko Kravets
d2d78e7676 Allow the user to decide how to handle null values in charts (#4071)
* getredash/redash#2629 Refactor Chart visualization, add option for handling NULL values (keep/convert to 0.0)

* Handle null values in line/area stacking code; some cleanup

* Handle edge case: line/area stacking when last value of one of series is missing

* Mjnor update to line/area stacking code

* Fix line/area normalize to percents feature

* Unit tests

* Refine tests; add tests for prepareLayout function

* Tests for prepareData (heatmap) function

* Tests for prepareData (pie) function

* Tests for prepareData (bar, line, area) function

* Tests for prepareData (scatter, bubble) function

* Tests for prepareData (box) function

* Remove unused file
2019-10-27 13:42:37 +02:00
Ran Byron
c74ece4dda Decrease size of widget pagination (#4120)
* Added tests

* Perhaps this would trigger percy

* Decrease size of widget pagination

* Removed unused attr

* Updated tests
2019-10-27 13:42:37 +02:00
Arik Fraimovich
4a74263522 Sync botocor eversions across requirements files. (#4128) 2019-10-27 13:42:37 +02:00
Levko Kravets
4edfd23772 Migrate Counter visualization to React (#4106)
* Migrate Counter to React: Renderer

* Migrate Counter to React: Editor

* Cleanup

* Review and fix rows indexing algorithm

* Counter not properly scaled in editor

* Fix wrong label for/input id pair

* Tests

* Tests

* Fix vendor prefixes

* Remove unnecessary useEffect dependencies

* Update tests

* Fix Percy snapshot names
2019-10-27 13:42:37 +02:00
Arik Fraimovich
c9b3c95464 Upgrade Sentry-SDK and enable additional integratoins (#4127)
* Update sentry-sdk version

* Add additional Sentry integrations
2019-10-27 13:42:37 +02:00
Ran Byron
959822cca6 Widget table scroll-x visible (#4101)
* Table viz horizontal scroll made visible

* Added tests

* Fixed snapshot pre-condition

* Perhaps this would trigger percy
2019-10-27 13:42:37 +02:00
Justin Clift
4dea1d681f Update botocore, to get pass pip warning (#4122) 2019-10-27 13:42:37 +02:00
sphenlee
49b3dcaff7 hive_ds: show a user friendly error message when possible (#4121) 2019-10-27 13:42:37 +02:00
Gabriel Dutra
b59e210d90 Use ng-src for data source icons (#4123) 2019-10-27 13:42:37 +02:00
Ran Byron
10b57b6ee2 Fix number param value normlization (#4116) 2019-10-27 13:42:37 +02:00
Arik Fraimovich
cc21a32369 Move annotation logic into Query Runner (#4113)
* Code formatting

* Move annotation logic into query runner, so it can be overriden in the query runner.

* Add mixin to __all__

* Switch to flag instead of mixin

* Feature (Redshift): option to set query group for adhoc/scheduled queries  (#4114)

* Add scheduled status to query job metadata.

* Add: option to set query group for adhoc/scheduled Redshift queries

* Scheduled might not be set for already enqueued queries.
2019-10-27 13:42:37 +02:00
swfz
966b59906f Display data source icon in query editor (#4119) 2019-10-27 13:42:37 +02:00
Arik Fraimovich
a8440d32ab Add ability to use Ant's Table loading property when using ItemsTable (#4117) 2019-10-27 13:42:37 +02:00
Gabriel Dutra
e7765440fc Fix Dropdown parameter options appearing behind Dialog (#4109) 2019-10-27 13:42:36 +02:00
Arik Fraimovich
8af099b658 Fix: allow users with view only acces to use the queries in Query Results (#4112)
* Fix: allow users with view only acces to access the queries

* Add tests

* Update error message

* Update error message. Take 2
2019-10-27 13:42:36 +02:00
Ran Byron
7e9db06633 Fix widget bottom element alignment (#4110) 2019-10-27 13:42:36 +02:00
Omer Lachish
194d4e1750 Update badge in README.md to link to CircleCI (#4104)
* Update README.md

* Update README.md

* Update README.md

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* Update README.md
2019-10-27 13:42:36 +02:00
shinsuke-nara
0207ba11a3 Migrate with SQL statements. (#4105) 2019-10-27 13:42:36 +02:00
Omer Lachish
61a80ad8cc Dashboard: when updating parameters, run only relevant queries (#3804)
* refresh only affected queries in dashboard when parameters are changed

* rename pendingParameters to updatedParameters

* select which widgets to update according to their mapping as a dashboard-level parameter

* use lodash's include
2019-10-27 13:42:36 +02:00
Sandeep Belagavi
cbfd994a28 [Qubole] - Adding support to process Quantum query types. (#4066)
* [Qubole] - Adding support to process Quantum query types.

Quantum is a serverless interactive service that offers
direct SQL access to user's data lake. Changes are made
to accept `quantum` query type from user which makes
`Cluster Label` as optional.

* -Making quantum as defult query.
-Dictionary safe access to connection parmeters

* keeping pep8 standards

* Maintainig pep8 std

* Use latest version of qds-sdk

* Use qds-sdk v1.13.0

* Use qds-sdk v1.12.0

* Use qds-sdk v1.13.0

* Updating SDK with verified version

* hive as default query type

* qds-sdk : Locking most recent release version

* qds-sdk : Locking recent release version

* falling back to original version of qds-sdk
2019-10-27 13:42:36 +02:00
Gleb Lesnikov
21ac9e8a97 [Data Sources] Add: Azure Data Explorer (Kusto) query runner (#4091)
* [Data Sources] Add: Azure Data Explorer (Kusto) query runner

* CodeClimate fixes

* Remove TODO

* Fixed configuration properties names for Azure Kusto

* Azure Kusto: get_schema in one query

* azure-kusto-data update to 0.0.32

* Add Kusto to the default query runners list
2019-10-27 13:42:36 +02:00
Arik Fraimovich
d53d05cfb9 Make sure we always pass a list to _get_column_lists (#4095)
(some data sources might return None as the columns list)
2019-10-27 13:42:36 +02:00
Ran Byron
ee85923b14 Removed redash-newstyle.less (#4017) 2019-10-27 13:42:36 +02:00
Arik Fraimovich
4866be60de Fix: MySQL connections without SSL are failing (#4090)
* Move connection logic into a single method & make sure not to pass ssl value if not used.

* Remove wildcard import and format file.
2019-10-27 13:42:36 +02:00
Christian Clauss
56d444b1a5 Add more flake8 tests and fail build if any test fails (#4055)
* Add more flake8 tests and fail build if any test fails

Run all flake8 E9xx + F63x + F7xx + F82x tests.

* long = long in Python 2
2019-10-27 13:42:36 +02:00
Gabriel Dutra
6b39437cdb Migrate Parameters component to React (#4006)
* Start Parameters Migration

* Add dirtyCount

* Use workaround with setState

* Apply Changes

* Add EditSettingsDialog

* Add Cmd/Ctrl + Enter behavior

* Remove isApplying

* Delete Angular version of parameters

* Update tests

* Remove angular stuff

* Update jest

* Drag placeholder

* Update events

* Use old button styling and move css

* Reviewing code

* Add parameter rearrange test

* Add Parameter Settings title change test

* Update Parameter Settings button styling

* Move parameter url logic back to Parameters

* Disable url update when query is new

* Styling changes (#4019)

* Ran's title width styling

* Update drag test

* Improve sizing for Number inputs

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* Fix issue with dragged parameter wrapping

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* Don't reevaluate dirtyParamCount

* Allow multiple values :)

* Fix parameter alignments

* Fix Select width on search

* Update client/app/components/Parameters.less

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* Humanize param.name

* Make sure angular updates Execute disabled status
2019-10-27 13:42:36 +02:00
Vladimir Ponarevsky
b426e4fdc4 Fix clickhouse password leak (#4078)
* Fix clickhouse password leak

* Fix after review
2019-08-18 11:05:41 +03:00
Arik Fraimovich
e5e926bac5 Pin kombu version (#4075)
kombu is a dependency of Celery and usually we let them declare the version, but their version is pinned and the most recent release (4.6.4) has a severe regression where workers stop responding to inspect commands.
2019-08-16 19:27:24 +03:00
Arik Fraimovich
24d68008fa Format target value as a number with reasonable default (#4073) 2019-08-15 15:54:32 +03:00
Arik Fraimovich
0e90b89acc ParameterizedQuery: handle the case where a value is null (#4072) 2019-08-15 15:18:40 +03:00
Jannis Leidel
2c2f241671 Require a more up-to-date version of importlib-metadata. (#4069) 2019-08-15 11:50:27 +03:00
Jakdaw
d49514abe9 When we fork a query, make sure we create the new visualizations in the same order as per the source query (#4067) 2019-08-14 11:08:56 +03:00
Arik Fraimovich
934a145ced Switch to mysqlclient from Python-MySQL (#4061) 2019-08-14 10:11:53 +03:00
Omer Lachish
f7c70c2b91 Add parameter dialog doesn't work when query has selected text (#4032)
* debounce updateQuery to prevent pasting parameters over selected texts failing parseQuery (see #4032)

* drop defer
2019-08-14 07:47:34 +03:00
The Alchemist
69ba165565 [Data Sources] Initial commit for adding Dgraph support (#3987)
* Initial commit for adding Dgraph support

* Made suggestions from https://codeclimate.com/github/getredash/redash/pull/3964

* feedback from @arikfr

* added logo for Dgraph from Twitter

* Better conversion of Dgraph JSON to Redash's internal JSON

* made recommendations from @arikfr

* removed unused function
2019-08-13 13:14:37 +03:00
Jannis Leidel
7b5696dc75 Fix loading of periodic tasks and clean up extension loading. (#4064)
* Fix loading of periodic tasks and clean up extension loading.

This does a few things:

- add tests for extension loading
- refactor the extension and periodic task loading
- better handle assertions raised by extensions (e.g. when an extension tries to override an already registered view)
- attach exception traceback to error log during loading for improved debugging

* Use site.addsitedir instead of calling pip.

* Use sys.path instead of site.addsitedir and also the setup.py egg_info command.
2019-08-13 13:11:59 +03:00
Gabriel Dutra
4698408a08 Cypress: Fix cy.clock not freezing time (#4060) 2019-08-13 07:08:59 -03:00
Ievgen Aleinikov
be142d60df Add assume role as a credential source for AWS Athena Query runner (#4028)
* allowing to specify a custom work group for AWS Athena queries

* Fixing title + adding correct position in the UI

* Adding assume role configuration to Athena query runner.

* removing extra blank lines

* fixes based on comments to the PR
2019-08-12 16:45:56 +03:00
Arik Fraimovich
aceea6516f Change the required Docker Compose version to 3.2 (#4059)
With the default Docker installed from sources on Ubuntu 19.04 it failed starting the project when asking for Compose version 3.7, but everything worked fine with 3.2.
2019-08-12 13:26:29 +03:00
Arik Fraimovich
685b53672e Prevent CSP violations by not having script URLs (#4062)
* Fix: remove inline script to avoid CSP violation

Closes #4039.

* Restore eslint rule that prevents javascript href attributes.

* Remove all inline script links.
2019-08-12 13:25:07 +03:00
Arik Fraimovich
7dd62ef948 Add option to control whether to format target value. (#4063) 2019-08-12 13:24:11 +03:00
Evghenii Goncearov
7c2acc34c9 Dont send password reset link to disabled users (#2631)
* Dont send password reset link to disabled users

* Update email subject

* Update blocked email text.

* Update blocked email text (plain text version).

* Remove debug print.
2019-08-11 17:29:26 +03:00
Arik Fraimovich
c5a90876f3 Add Cassandra to the list of default enabled query runners (#4058) 2019-08-11 17:17:57 +03:00
Takuya Arita
8abaf89394 Add tag management commands (#3168) 2019-08-11 16:30:48 +03:00
PengYuan Lai
aa2bd0042e check float if scale > 0 in snowflake query result (#3876) 2019-08-11 16:21:57 +03:00
Yoshiken
a7b14bfb9a Fix according to pycodestyle format (#4011)
* Fix W292 no newline at end of file

* Fix extra whitespace

* Fix E305 expected 2 blank lines after class or function definition

* Fix W391 blank line at end of file

* Fix E231 missing whitespace after

* Fix E303 too many blank lines

* Fix E302 expected 2 blank lines

* Fix E128 continuation line under-indented for visual indent
2019-08-11 16:09:04 +03:00
Oluwafemi Sule
4e5f55a4b7 Align content vertically in restricted widget type (#4056) 2019-08-11 15:28:46 +03:00
Omer Lachish
76fbe858ba refresh_queries requires Request Context (#4045)
* avoid using 'abort' in parameterized query - raise an exception instead

* when facing invalid parameters or detached dropdown queries - continue to refresh the rest of the outdated queries

* test that dropdown queries detached from data source raise an exception when fetch values is attempted

* test that queries with invalid parameters arent refreshed

* test that queries with dropdown query parameters which are detached from the data source are skipped

* fix stale test double name

* newlines. newlines everywhere.

* pass org into dropdown_values

* pass in org in every ParameterizedQuery usage

* Update redash/tasks/queries.py

Co-Authored-By: Arik Fraimovich <arik@arikfr.com>

* reduce refresh_queries log noise

* track failure count for queries that failed to apply parameters, and also notify the failures

* Update redash/tasks/queries.py

Co-Authored-By: Arik Fraimovich <arik@arikfr.com>

* newlines. newlines everywhere.
2019-08-09 15:26:31 +03:00
Omer Lachish
cf7aef1e16 Make sure there is an event for any query execution (#4051)
* move event recording for query executions inside run_query

* include indication of cache hit or miss inside execute_query events
2019-08-09 15:24:17 +03:00
Jannis Leidel
77625b2a13 Remove duplicate base_url function. (#4043) 2019-08-08 10:44:44 +03:00
Omer Lachish
c4dcf01b3c avoid variable shadowing (#4050) 2019-08-07 22:43:04 +03:00
Ran Byron
a167c590b6 Added arrow to multi-select component (#4044) 2019-08-06 16:46:53 +03:00
Gabriel Dutra
8e23f93433 Allow dynamic values dropdown to scroll with the page (#4040) 2019-08-06 08:55:25 -03:00
Levko Kravets
e41d40bbe0 getredash/redash#4036 Visualisation editor crashes out when changing datetype to non-HTML text (#4037) 2019-08-05 12:37:21 +03:00
Gabriel Dutra
6fc4d5b551 Focus DatePicker after selecting dynamic values (#4033) 2019-08-04 22:24:33 -03:00
Gabriel Dutra
f0576a3623 Support multi-select in parameters (#3952)
* Allow multiple values for enum parameter

* Allow multi-select for Query dropdown parameters

* CR + make sure list values are allowed

* Add prefix, suffix and separator

* Rename multipleValues and cast options as strings

* Replicate serialization logic on frontend

* Add Quote Option Select

* Make sure it's enum or query before join

* Add a couple of tests

* Add help to quote option

* Add min-width and normalize empty array

* Improve behavior when changing parameter settings
- Set parameter value again to pass through checks
- Add setValue check for multi values

* Validate enum values on setValue + CodeClimate

* Ran wording suggestions

* Updates after Apply Changes

* Fix failing Cypress tests

* Make sure enumOptions exists before split

* Improve propTypes for QueyBasedParameterInput

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* CR

* Cypress: Test for multi-select Enum

* Fix multi-selection Cypress spec

* Update Refresh Schedule
2019-08-04 15:47:30 +03:00
Levko Kravets
9eabf89771 getredash/redash#4031 Counter visualization: formatting not applied to target value (#4035) 2019-08-04 15:22:53 +03:00
Arik Fraimovich
11cc274c1c Update Snowflake connector version to latest (#4029) 2019-08-04 08:55:02 +03:00
Ran Byron
8ad08a566a Revert "Revoked widget refresh button spinners" (#4027)
This reverts commit ab5494a8fd.
2019-08-01 08:23:17 +03:00
Levko Kravets
ef31d0d768 Fix: don't update dashboard's version when adding a widget (#4026) 2019-07-31 22:28:12 +03:00
Levko Kravets
4640c33387 Bug fix: error when trying to collect dashboard-level filters for a textbox widget (#4024) 2019-07-31 18:03:40 +03:00
Levko Kravets
9b290913a6 Migrate Table visualization to React Part 1: Renderer (#3963) 2019-07-31 17:33:33 +03:00
Ran Byron
db89c4f7bc Turned off max asset size warning (#4023) 2019-07-31 11:34:52 +03:00
Gabriel Dutra
eae1fb7d73 Force readonly inputs click (#4016) 2019-07-30 11:16:34 +03:00
Arik Fraimovich
4f742aeaac Fix: support for unicode in DynamoDB queries (#4015) 2019-07-30 11:14:57 +03:00
Ran Byron
5ddad862be Updated timeago strings (#4012)
* Updated timeago strings

* Moved moment config to app/config
2019-07-29 18:03:59 +03:00
Ran Byron
6f811f163a Added widget header refresh indicator (#3970) 2019-07-29 16:43:44 +03:00
Omer Lachish
7fb33e3ebb Failed Scheduled Queries Report (#3798)
* initial work on e-mail report for failed queries

* send failure report only for scheduled queries and not for adhoc queries

* add setting to determine if to send failure reports

* add setting to determine interval of aggregated e-mail report

* html templating of scheduled query failure report

* break line

* support timeouts for failure reports

* aggregate errors within message and warn if approaching threshold

* handle errors in QueryExecutor.run instead of on_failure

* move failure report to its own module

* indicate that failure count is since last report

* copy changes

* format with <code>

* styling, copy and add a link to the query instead of the query text

* separate reports with <hr>

* switch to UTC

* move <h2> to actual e-mail subject

* add explicit message for SoftTimeLimitExceeded

* fix test to use soft time limits

* default query failure threshold to 100

* use base_url from utils

* newlines. newlines everywhere.

* remove redundant import

* apply new design for failure report

* use jinja to format the failure report

* don't show comment block if no comment is provided

* don't send emails if, for some reason, there are no available errors

* subtract 1 from failure count, because the first one is represented by 'Last failed'

* don't show '+X more failures' if there's only one

* extract subject to variable

* format as text, while we're at it

* allow scrolling for long exception messages

* test that e-mails are scheduled only  when beneath limit

* test for indicating when approaching report limits + refactor

* test that failures are aggregated

* test that report counts per query and reason

* test that the latest failure occurence is reported

* force sending reports for testing purposes

* Update redash/templates/emails/failures.html

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* Update redash/templates/emails/failures.html

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* Update redash/tasks/failure_report.py

* add org setting for email reports

* remove logo from failure report email

* correctly use the organization setting for sending failure reports

* use user id as key for failure reports data structure

* Update redash/tasks/failure_report.py

Co-Authored-By: Arik Fraimovich <arik@arikfr.com>

* build comments while creating context for e-mail templates

* figure out the base url when creating the e-mail

* no need to expire pending failure report keys as they are deleted anyway when sent

* a couple of CodeClimate changes

* refactor key creationg to a single location

* refactor tests to send e-mail from a single function

* use beat to schedule a periodic send_aggregated_errors task instead of using countdown per email

* remove pending key as it is no longer required when a periodic task picks up the reports to send

* a really important blank line. REALLY important.

* Revert "a really important blank line. REALLY important."

This reverts commit c7d8ed8972.

* a really important blank line. REALLY important. It is the best blank line.

* don't send failure emails to disabled users
2019-07-28 12:40:54 +03:00
Omer Lachish
f165168860 recycle gunicorn workers (#4013) 2019-07-28 11:39:14 +03:00
Gabriel Dutra
86b0608fde Fix Apply Changes is lost when query is edited (#4010)
Co-Authored-By: Ran Byron <ranbena@gmail.com>
2019-07-27 19:05:49 -03:00
Gabriel Dutra
cd4daf8823 Add Dynamic Values to Date and Date Range Parameters (#3904)
* Draft for Date Dynamic values

* Use value with prefix instead of specific attr

* Fix not possible to select static value

* Update antd version

* Cleanup and DateRangeParameter

* Dynamic DateTimeRange

* Add Dynamic options to Date Parameters

* UI refinements

* Add getDynamicValue function

* Add 'This' options and prevent text clipping

* Make allowClear available

* Update ScheduleDialog snapshot

* Add some protections and separate Date/DateRange

* Accept null values on date or daterange parameters

* Handle undefined values on Moment propType

* Move export to end of files

* Remove Today/Now option

* Update with Apply Changes

* Show name instead of value for dynamic values

* Add comment about supporting useCurrentDateTime

* Cypress Tests: Date Parameters

* Cypress Tests: Date Range Parameters

* Don't put null params in the url

* Add workaround comments to Cypress tests

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* Fix Dynamic Value as default for global parameters

* Update Back to Static Value

* Add isValid to value on Date and DateRange inputs

* CR suggestions

* Fix Back to Static Value for Dates

* Update Dynamic Value Styling

* Fix failing Date tests

* Fix selectedDynamicValue

* Parameter spec: Remove date range clickThrough

* Add transition

* Fix failing Cypress tests

* Back with 'width: auto'

* Check value is valid on Back to Static value

* CR

* Update Date Range width
2019-07-26 22:40:13 +03:00
Gabriel Dutra
78cae474e0 Cypress: Specify widgets position on sharing spec (#4009) 2019-07-26 13:15:39 -03:00
Naoyuki Kataoka
c518c7a4bc Modified PagerDuty destination to avoid an error for multi-byte characters (#4008) 2019-07-24 09:06:25 +03:00
Gabriel Dutra
8c2f51d09d Percy: Fix shared dashboard inconsistent snapshots (#4002) 2019-07-23 11:55:24 -03:00
Gabriel Dutra
6f6c68bd79 Cypress: Separate dashboard spec (#4003) 2019-07-22 11:09:08 -03:00
Ran Byron
64f274f58e Disable execute when params are dirty (#4001)
* Disable execute when params dirty

* Removed special apply handling for query page

* Updated tests
2019-07-22 12:13:34 +03:00
Omer Lachish
dd89bd885f Add "deprecated" flag to query runners (and alert destinations) (#3972)
* add a deprecated flag to query runners and show only non-deprecated query runners when adding a new data source

* add a deprecated flag to alert destinations and show only non-deprecated alert destinations when adding a new alert destination

* add a deprecated() decorator for a more succint way to deprecate

* deprecate URL query runner and HipChat alert destination

* use class properties instead of class methods for deprecation

* I <3 newlines
2019-07-22 10:36:31 +03:00
Ran Byron
b2295197cf Added publish notification to query rename (#3998) 2019-07-21 15:01:08 +03:00
Omer Lachish
ea0e411053 Return unsafe sharing error from backend (#3990)
* return message explaining unsafe sharing

* use backend-generated message for public dashboards

* use backend-generated message for embeds

* Update redash/handlers/query_results.py

Co-Authored-By: Arik Fraimovich <arik@arikfr.com>

* refactor simple (non-interpolated) query result handler error messages to a single location

* use error_messages to test out unsafe error messages (along with a couple of others)

* Update redash/handlers/query_results.py

Co-Authored-By: Ran Byron <ranbena@gmail.com>

* Update redash/handlers/query_results.py

Co-Authored-By: Arik Fraimovich <arik@arikfr.com>
2019-07-21 09:21:45 +03:00
Arik Fraimovich
9bdb3412a5 Move query runners/destinations import from redash.app to redash. (#3993)
* Move query runners/destinations import from redash.app to redash.

* Add missing argument
2019-07-21 09:05:29 +03:00
Fumiya Karasawa
ad4a760545 Search dropdown parameters (#3796) 2019-07-20 16:07:03 +03:00
Omer Lachish
c1f4147807 Avoid committing it.only (#3995)
* remove it.only, left by mistake

* use no-only-tests

* 'off' should be used instead of 'none'

* Dedup jest/only rule

* always error for .only
2019-07-19 18:30:38 +03:00
Ran Byron
c054ae8be0 Fixed filter style issue (#3996) 2019-07-18 13:49:37 +03:00
Omer Lachish
d1edd3d068 Query Result API response shouldn't include query information for non authenticated users (#3985)
* avoid catching errors on text widgets' load(), as they don't have a visualization and therefore do not return any promise

* throw error when failing to load widgets on public dashboards - in case something needs to be done with it at a later time, and it's the right thing to do anyway

* use Promise.resolve instead of checking for undefined

* call serialize_query_result instead of directly calling to_dict

* filter unneeded query result fields for unauthenticated users

* test for serialization filtering

* lint

* use project instead of list comprehension
2019-07-18 12:12:49 +03:00
Arik Fraimovich
4989bfae60 Remove custom Redis connection code in favor of redis.from_url (#3992) 2019-07-18 12:03:52 +03:00
Gabriel Dutra
f20a020003 Use AceEditor for Query Snippets (#3973)
Co-Authored-By: Ran Byron <ranbena@gmail.com>
2019-07-17 13:47:31 -03:00
Ran Byron
01da8c158a Parameter “Apply Changes” button (#3907) 2019-07-17 17:17:39 +03:00
Omer Lachish
c83e40b047 Celery doesn't auto reload in development (#3898)
* pick up *.py file changes and restart scheduler

* only watch /redash in order to avoid reloading on other file changes (e.g. tests)

* add dev_scheduler entrypoint

* use exec

* Update bin/docker-entrypoint

* rename dev_scheduler to dev_worker

* use same defaults as worker
2019-07-17 10:38:56 +03:00
Ran Byron
c3cc65a21d Viz embed logo alignment (#3956) 2019-07-16 11:37:31 +03:00
Omer Lachish
5929139ab8 A couple of parameters-on-public-dashboards loose ends (#3988)
* avoid catching errors on text widgets' load(), as they don't have a visualization and therefore do not return any promise

* throw error when failing to load widgets on public dashboards - in case something needs to be done with it at a later time, and it's the right thing to do anyway

* use Promise.resolve instead of checking for undefined
2019-07-16 10:48:37 +03:00
Ran Byron
66794acd1f Added loading indicator to public dashboard (#3984) 2019-07-16 10:31:19 +03:00
Arik Fraimovich
bce0832e48 Show error in case of failing to load a dashboard (#3983) 2019-07-15 22:13:46 +03:00
Yuri Grishaev
9f006997a0 mattermost needs whitespace to use h4 heading (#3981)
#### Even Smaller Heading - good
####Even Smaller Heading - bad
2019-07-15 21:13:17 +03:00
Omer Lachish
51d8131db5 Allow Parameters on Public Dashboards (#3659)
* change has_access and require_access signatures to work with the objects that require access, instead of their groups

* use the textless endpoint (/api/queries/:id/results) for pristine
queriest

* Revert "use the textless endpoint (/api/queries/:id/results) for pristine"

This reverts commit cd2cee7738.

* go to textless /api/queries/:id/results by default

* change `run_query`'s signature to accept a ParameterizedQuery instead of
constructing it inside

* raise HTTP 400 when receiving invalid parameter values. Fixes #3394

* enqueue jobs for ApiUsers

* rename `id` to `user_id`

* support executing queries using Query api_keys by instantiating an ApiUser that would be able to execute the specific query

* show deprecation messages for ALLOW_PARAMETERS_IN_EMBEDS. Also, move
other message (email not verified) to use the same mechanism

* add link to forum message regarding embed deprecation

* change API to /api/queries/:id/dropdowns/:dropdown_id

* split to 2 different dropdown endpoints and implement the second

* add test cases for /api/queries/:id/dropdowns/:id

* use new /dropdowns endpoint in frontend

* first e2e test for sharing embeds

* Pleasing the CodeClimate overlords

* All glory to CodeClimate

* remove residues from bad rebase

* add query id and data source id to serialized public dashboards

* add global parameters directive to public dashboards page

* allow access to a query by the api_key of the dashboard which includes
it

* rename `object` to `obj`

* simplify permission tests once `has_access` accepts groups

* support global parameters for public dashboards

* change has_access and require_access signatures to work with the objects that require access, instead of their groups

* rename `object` to `obj`

* simplify permission tests once `has_access` accepts groups

* no need to log `is_api_key`

* send parameters to public dashboard page

* allow access to a query by the api_key of the dashboard which includes it

* disable sharing if dashboard is associated with unsafe queries

* remove cypress test added in the wrong place due to a faulty rebase

* add support for clicking buttons in cy.clickThrough

* Cypress test which verifies that dashboards with safe queries can be shared

* Cypress test which verifies that dashboards with unsafe queries can't be shared

* remove duplicate tests

* use this.enabled and negate when needed

* remove stale comment

* add another Cypress test to verify that unauthenticated users have access to public dashboards with parameters

* obviously, I commit 'only' the first time I use it

* search for query access by query id and not api_key

* no need to fetch latest query data as it is loaded by frontend from the textless endpoint

* test that queries associated with dashboards are accessible when supplying the dashboard api_key

* propagate `isDirty` down to `QueryBasedParameterInput`

* go to /api/:id/dropdown while editing a query, since dropdown queries might still not be associated with the parent. see #3711

* show helpful error message if dropdown values cannot be fetched

* use backticks instead of line concatenation

* remove requirement to have direct access to dropdown query in order validate it. parent query association checks are sufficient

* remove isDirty-based implementation and allow dropdown queries through nested ACL even if they aren't associated yet (given that the user has _direct_ access to the dropdown query)

* fix tests to cover all cases for /api/queries/:id/dropdowns/:id

* fix indentation

* require access to the query, not the data source

* resolve dashboard user by query id

* apply new copy to Cypress tests

* if only something would have prevented me from commiting an 'only' call 🤔

* very important handling of whitespace

* respond to parameter's Apply button

* text widgets are safe for sharing

* remove redundant event

* add a safety check that object has dashboard_api_keys before calling it

* supply a parameter value for text parameters to have it show up

* add parameter values for date and datetime

* use the current year and month to avoid pagination

* use Cypress.moment() instead of preinstalled moment()

* explicitly create parameters

* refresh query data if a  querystring parameter is provided

* avoid sending a data_source_id - it's only relevant to unsaved queries, since a saved query's data_source is available in the backend

* remove empty query text workaround

* provide default value to parameter

* add a few more dashboard sharing specs

* lint

* wait for DynamicTable to appear to reveal that actual results are displaying

* override error message for unsafely shared widgets
2019-07-15 15:09:30 +03:00
Arik Fraimovich
c793b5dd11 Remove explicit kombu dependency (#3978)
We used an explicit kombu dependency to target the correct Redis version, but current version of Celery supposed to use it by default.
2019-07-14 08:47:15 +03:00
Levko Kravets
4e9da3f116 [Bug fix] Plotly legend overlaps plot on small screens (when legend clicked) (#3976) 2019-07-13 18:27:53 +03:00
Arik Fraimovich
15a8eecdde JSON Data Source (#3805)
* WIP: JSON Data Source

* Add JSON data source to default list
2019-07-11 14:23:38 +03:00
k-tomoyasu
a8ff2500be Build custom alert message (#3137)
* build custom alert message

* fit button color tone

* pass existing test

* fix typos

* follow code style

* add webhook alert description and avoid key error

* refactor: create alert template module

* follow code style

* use es6 class, fix template display

* use alerts.options, use mustache

* fix email description

* alert custom subject

* add alert state to template context, sanitized preview

* remove console.log 🙇

* chatwork custom_subject

* add alert custom message. pagerduty, mattermost, hangoutschat

* Pass custom subject in webhook destination

* Add log message when checking alert.

* Add feature flag for extra alert options.
2019-07-11 13:23:06 +03:00
Ran Byron
7bf84e856c Workaround fixes for datepicker display bug in Cypress tests (#3967) 2019-07-10 17:47:46 +03:00
Levko Kravets
5149bf67ca [Bug fix] Archiving a dashboard cause widgets to show reload spinner forever (#3968) 2019-07-10 13:35:15 +03:00
Arik Fraimovich
93449db325 Improvements to Query Result serialization code (#3960)
* Fix: allow serializing empty or bad dates

* Improve date serialization performance

* Remove duplicate assertion.
2019-07-10 10:51:34 +03:00
Arik Fraimovich
df57d22e81 Add explicit route for dashboards to allow embedding in iframes. (#3957)
* Add explicit route for dashboards to allow embedding in iframes.

* Add missing blank lines
2019-07-10 10:25:08 +03:00
Gabriel Dutra
de0a44ee85 Migrate Query Snippets to React (#3627) 2019-07-09 09:27:39 -03:00
Tomoki Sekiyama
261062d491 Support multi-byte search for query names and descriptions (#3908)
* Support multi-byte search for query names and descriptions

* add multi_byte_support_enabled option to organization settings

* add `ilike %...%` to query search conditions when the option is enabled

* Improve description for multi_byte_search_enabled option

Co-Authored-By: Arik Fraimovich <arik@arikfr.com>

* Remove tsvector from search when multi_byte_search_enabled

* Add a multi-byte search test case
2019-07-08 10:01:47 +03:00
Arik Fraimovich
1878e8bf90 Add additional Celery config options (#3961) 2019-07-08 08:32:18 +03:00
Arik Fraimovich
47fc8a942a Add options to hide different parts of embed UI (parameters, title, link to query) (#3955)
* Move closing tag to correct location

* Add options to hide elements in query embed UI

* Fix for headless top padding (#3959)
2019-07-07 14:34:00 +03:00
Arik Fraimovich
addecbdd8f Allow calling query results endpoint without parameters. (#3958)
* Allow calling query results endpoint without parameters.

* Fix: allow serializing empty or bad dates

* Revert "Fix: allow serializing empty or bad dates"

This reverts commit cc49319d9e.
2019-07-07 14:22:08 +03:00
Gabriel Dutra
baec5d56f5 Remove time from Date column in filters (#3953) 2019-07-05 18:13:12 -03:00
Levko Kravets
1f4325ba8d Migrate Box Plot visualization to React (#3948) 2019-07-04 22:25:09 +03:00
Arik Fraimovich
5e5b56ed6a Fix: render date/time values as strings and not epoch time (#3951) 2019-07-04 20:49:59 +03:00
Arik Fraimovich
45a3b72730 Update fsevents to v1.2.9 (#3950) 2019-07-04 15:43:43 +03:00
Levko Kravets
cc48de0d8f Migrate Word Cloud visualization to React (#3930) 2019-07-03 13:29:05 +03:00
Arik Fraimovich
300f3f6780 Fix: waiting tasks are not shown in admin view (#3942)
* Fix: waiting tasks are not shown properly

* Added a comment.
2019-07-03 11:09:42 +03:00
Omer Lachish
2e4a69cba4 Parameter spec fixes (#3932)
* supply a parameter value for text parameters to have it show up

* add parameter values for date and datetime

* use the current year and month to avoid pagination

* use Cypress.moment() instead of preinstalled moment()

* capture time before clicking on Now

* use now from input

* use now from input for another test
2019-07-01 09:56:55 +03:00
Arik Fraimovich
6748e9a15d Add option to hide Pivot Table totals (#3943)
* Add option to hide Pivot Table totals

* Simplify implementation using DEFAULT_OPTIONS.

* Flip hide pivot controls to show pivot controls

* Update client/app/visualizations/pivot/Editor.jsx

Co-Authored-By: Ran Byron <ranbena@gmail.com>
2019-06-30 15:43:18 +03:00
Arik Fraimovich
7ceb68a468 Visualization: details view (#3778)
* Details visualization

* Add PropTypes and guard against no rows
2019-06-30 15:34:02 +03:00
Levko Kravets
3c1d1e3d4e Explicitly mark default visualization (#3944) 2019-06-30 14:09:00 +03:00
Levko Kravets
92391e3cbc [Bug fix] Toggling Use Dashboard Level Filter cause widgets to show reload spinner forever (#3939) 2019-06-28 15:02:07 +03:00
Levko Kravets
17438002d7 [Bug fix] Adding widget from query page is broken (#3921) 2019-06-25 20:06:36 +03:00
Jianchao Yang
a00c5a8857 Dockerfile front end stage copies client side files only (#3924)
So that changing other files will not trigger the
very expensive rebuild process.
2019-06-23 11:33:52 +03:00
John Karahalis
a696fa55f3 Set unique class name for Query Control Dropdown (#3922)
This will help me target the Query Control Drodpwon in my extension.
2019-06-23 10:01:46 +03:00
Arik Fraimovich
27259b5abe Add support for int/float values in guess_type (#3913) 2019-06-20 08:55:31 +03:00
Ran Byron
9ee393ec75 Fix schedule dialog needless confirm saves (#3919) 2019-06-20 08:54:53 +03:00
Ran Byron
cfafa97218 Fixed boolean filter (#3915) 2019-06-19 14:54:27 +03:00
deecay
be580b24a5 Expose celery job timeout setting to env var (#3912)
* Expose celery job timeout to env

* Change variable name
2019-06-19 14:41:12 +03:00
Arik Fraimovich
a6960c5f19 Fix: time format option was wrong (#3916) 2019-06-19 14:37:02 +03:00
Arik Fraimovich
6dd321beeb Rockset: handle query errors (#3910) 2019-06-18 14:10:25 +03:00
Arik Fraimovich
27c64b42ac Add keyboard shortcut for format query (Ctrl/Cmd+Shift+F) (#3911)
* Add keyboard shortcut for format query

* Added to button tooltip
2019-06-18 14:10:05 +03:00
deecay
99bf6d122c Custom Map Markers (#3840) 2019-06-18 09:50:09 +03:00
Ran Byron
d617f57f7d Increase celery job timeout (#3903) 2019-06-17 08:35:02 +03:00
Guy Cohen
21a27ee0b1 Fix OverflowError on celery worker (#3899) 2019-06-16 11:34:29 +03:00
YOSHIDA Katsuhiko
ac293c7f92 Add alert deletion confirmation dialog (#3902) 2019-06-15 14:28:55 +03:00
YOSHIDA Katsuhiko
8e38dcd244 Support regenerating Query API Key (#3764)
* Add regenerate function of query's API Key

* Add regenerate API Key button

* Add regenerate Query API Key tests

* Fix too long line

* Replace  with this

* Return a simple version query

* Update only API Key

* Update API Key via query
2019-06-12 13:09:21 +03:00
Aidarbek Suleimenov
2bab144107 Celery task to clear schedule was added (#3801)
* Celery task to clear schedule was added

* fix formating

* empty_schedules task was put in separate task

* worker interval changed, new tests added

* past artifact deleted

* test queries moved to right class, lambda was used to filter data

* unnecessary changes eliminated

* more unnecessary files deleted

* line shortened

* Line shortened more

* codeclimate changes

* Unused test deleted, logs added
2019-06-12 13:07:15 +03:00
Mike Nason
4e0a251034 Add support ssl connections to redis (#3848)
* Add support ssl connections to redis

* Fix line length

* Update redash/__init__.py w suggestion

Co-Authored-By: Omer Lachish <omer@rauchy.net>

* Cleanup init after suggestion

* Move redis SSL config to settings

* Do not pass celery SSL config unless necessary

* Fix typo
2019-06-12 13:04:34 +03:00
Levko Kravets
7a9f4b07e0 Force a Choose account step for Google OAuth (#3884) 2019-06-12 11:48:25 +03:00
Arik Fraimovich
1630cbb904 Google Sheets: friendlier error message in case of an API error and more reliable test connection (#3883)
* Google Sheets: friendlier error message in case of an APIError and more reliable test connection.

* Pleasing the pep8 gods
2019-06-12 11:46:59 +03:00
Levko Kravets
f8d05dda9f getredash/redash#3879 Plotly legent overlaps plot on small screens (FF only) (#3882) 2019-06-12 11:46:34 +03:00
Omer Lachish
2af8b39d21 Authorize according to API key (if given) over cookies (#3877)
* remove legacy session identifier support

* remove redundant test

* redirect to login to support any invalid session identifiers

* be more specific with caught errors

* use authorization according to api_key (if provided) over session
2019-06-12 11:45:28 +03:00
dependabot[bot]
3faed0fdfe Bump pyopenssl from 16.2.0 to 17.5.0 (#3872)
Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 16.2.0 to 17.5.0.
- [Release notes](https://github.com/pyca/pyopenssl/releases)
- [Changelog](https://github.com/pyca/pyopenssl/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pyca/pyopenssl/compare/16.2.0...17.5.0)
2019-06-12 11:38:47 +03:00
dependabot[bot]
e45f49b86e Bump cryptography from 2.0.2 to 2.3 (#3870)
Bumps [cryptography](https://github.com/pyca/cryptography) from 2.0.2 to 2.3.
- [Release notes](https://github.com/pyca/cryptography/releases)
- [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/2.0.2...2.3)
2019-06-12 11:38:38 +03:00
Omer Lachish
e33ad3b164 Query Results: querying a column with a dictionary or array fails (#3887)
* flatten lists and dicts to json to be used with SQLite's json_extract functions

* add test that verifies that lists and dicts are saved

* add test that verifies that lists and dicts are saved
2019-06-11 17:41:15 +03:00
Omer Lachish
6605f62f3a add api_key to embed download urls (#3896) 2019-06-11 14:02:02 +03:00
Omer Lachish
ed2ac407ab Remove schema after deleting data source (#3894)
* remove schema from redis after deleting data sources

* switch to _pause_key to property
2019-06-10 22:39:26 +03:00
Ran Byron
dda75cce24 Drawer menu with recreated close button (#3889)
* Drawer menu with recreated close button

* Added “Open in new window” drawer menu button (#3890)
2019-06-09 12:21:53 +03:00
Omer Lachish
5b780ac460 Refresh Public Dashboards (#3881)
* remove legacy session identifier support

* remove redundant test

* redirect to login to support any invalid session identifiers

* be more specific with caught errors

* fix refresh for public dashboards
2019-06-06 11:07:12 +03:00
koooge
c0e8ef3000 Upgrade gspread 3.1.0 for supporting team drive (#3838)
* Upgrade gspread 3.1.0 for supporting team drive

Signed-off-by: koooge <koooooge@gmail.com>

* Revert "Upgrade gspread 3.1.0 for supporting team drive"

This reverts commit e53e8cb75b.

* Upgrade gspread 3.1.0 for supporting team drive

Signed-off-by: koooge <koooooge@gmail.com>

* Update Sheets query runner name
2019-06-06 11:02:08 +03:00
Gabriel Dutra
a82fd0cabc Cypress: Fix date parameters false positive (#3873) 2019-06-04 12:19:55 -03:00
Ran Byron
0e3e2eaf38 Restrict dynamic-table internal scroll only when pagination appears (#3875) 2019-06-04 09:11:19 -06:00
Omer Lachish
05f6ef0fb6 Remove legacy session identifier support (#3866)
* remove legacy session identifier support

* remove redundant test

* redirect to login to support any invalid session identifiers

* be more specific with caught errors
2019-06-03 22:18:24 +03:00
dependabot[bot]
e433efebc4 Bump flask from 0.11.1 to 0.12.3 (#3871)
* Bump flask from 0.11.1 to 0.12.3

Bumps [flask](https://github.com/pallets/flask) from 0.11.1 to 0.12.3.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/master/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/0.11.1...0.12.3)

* Bump to Flask 0.12.4 to fix an issue
2019-06-03 18:04:43 +03:00
Arik Fraimovich
a9588eac79 Update version to 8.0.0-beta. (#3869) 2019-06-02 14:37:16 +03:00
Arik Fraimovich
090b570a71 Rearrange README badges. 2019-06-02 14:01:09 +03:00
Arik Fraimovich
60b12e3121 Update PromiseRejectionError to show error message from API response (#3868)
* Update PromiseRejectionError to show error message from API response

* Update version to 8.0.0-beta.

* Revert "Update version to 8.0.0-beta."

This reverts commit c8fa74967f.
2019-06-02 11:47:26 +03:00
Arik Fraimovich
3f8c7333be Use the debian flavor of the redash/base image (#3240) 2019-06-02 11:42:19 +03:00
Arik Fraimovich
be8dec5f04 Add Collapse component (#3867) 2019-06-02 11:12:37 +03:00
Levko Kravets
10b3b50f3d getredash/redash#3862 Widget menu should not be available on public dashboards (#3863) 2019-06-02 11:00:10 +03:00
Levko Kravets
6f290ddfa1 Use more specific CSS for public dashboard page to avoid conflicts with other components (#3864) 2019-05-31 23:41:43 +03:00
Ran Byron
10b62ebe02 Beautify autoheight code a bit (#3865) 2019-05-31 14:09:29 -06:00
Ran Byron
04453409da Fix widget autoHeight related exception (#3858) 2019-05-31 05:57:26 -06:00
Gabriel Dutra
b27df216f4 Cypress tests for query parameters (#3810) 2019-05-30 10:01:44 -03:00
Levko Kravets
a0c76d777b getredash/redash#3851 Disable filter which does not have values (#3852) 2019-05-30 15:53:28 +03:00
Levko Kravets
2e96e2fb98 getredash/redash#3849 Fix initialization of dashboard-level parameters (#3853) 2019-05-30 15:51:58 +03:00
Gabriel Dutra
c2e31f040d Fix table isn't displayed with date filter (#3842) 2019-05-29 09:40:25 -03:00
Arik Fraimovich
816f4d912f Capitalize "elsewhere" (#3844) 2019-05-29 14:47:17 +03:00
Arik Fraimovich
9292ae8d3f CSV: correctly serialize booleans and dates. (#3841)
* CSV: correctly serialize booleans and dates.

Closes #3736, closes #2751.

* pep8 fixes

* Move column iteration to a helper function.

* Use elif, as types are mutually exclusive.

* Refactor parsing implementation.

* Move the csv generation fucntion
2019-05-29 10:45:29 +03:00
Levko Kravets
9480d89e4c [Feature] Migrate CreateDashboardDialog to React (#3826) 2019-05-27 23:12:52 +03:00
Ran Byron
5dff5b929c Unsupported browser redirect script outputted to file (#3832) 2019-05-27 13:18:04 -06:00
Gabriel Dutra
28e9740e2f Update Data Source Lifecycle Events (#3828) 2019-05-27 19:28:16 +03:00
Ran Byron
7679df63ba Fix for Mac browser’s scrollbar obscuring content (#3830) 2019-05-27 18:31:02 +03:00
Jannis Leidel
07c9530984 Decouple extensions from Flask app. (#3569)
* Decouple extensions from Flask app.

This separates the extension registry from the Flask app and also introduces a separate registry for preriodic tasks.

Fix #3466.

* Address review feedback.

* Update redash/extensions.py

Co-Authored-By: jezdez <jannis@leidel.info>

* Minor comment in requirements.

* Refactoring after getting feedback.

* Uncoupled bin/bundle-extensions from Flas app instance.

* Load bundles in bundle script and don’t rely on Flask.

* Upgraded to importlib-metadata 0.9.

* Add missing requirement.

* Fix TypeError.

* Added requirements for bundle_extension script.

* Install bundles requirement file correctly.

* Decouple bundle loading code from Redash.

* Install bundle requirements from requirements.txt.

* Use circleci/node for build-docker-image step, too.
2019-05-26 14:56:02 +03:00
Rueian
aecd0bf37a include bigquery_gce.png in db-logos (#3825) 2019-05-26 13:27:29 +03:00
Omer Lachish
4143bd3f20 when authenticated, the query shouldn't be sent over to the /jobs endpoint (#3831) 2019-05-26 12:19:32 +03:00
Arik Fraimovich
020dc35faf Create SECURITY.md (#3823)
* Create SECURITY.md

* Update SECURITY.md

Co-Authored-By: Gabriel Dutra <nesk.frz@gmail.com>
2019-05-26 09:32:41 +03:00
Gabriel Dutra
d7b03bac02 Add CircleCI env vars to Cypress docker (#3827) 2019-05-23 22:33:23 +03:00
Omer Lachish
29875e66d4 Plug custom Celery tasks via dynamic settings (#3819)
* plug custom celery tasks via dynamic settings

* an extra blank line
2019-05-22 11:37:18 +03:00
taminif
d97ce15837 refactor format semi-colon (#3812) 2019-05-22 11:10:16 +03:00
Gabriel Dutra
b263bb7077 [Bug fix] Fix "Now" in DateTime parameter not working (#3808) 2019-05-16 18:42:48 +03:00
Ran Byron
606cf12e74 Dashboard grid React migration #1 (#3722)
* Dashboard grid React migration

* Updated tests

* Fixes comments

* One col layout

* Tests unskipped

* Test fixes

* Test fix

* AutoHeight feature

* Kebab-cased

* Get rid of lazyInjector

* Replace react-grid-layout with patched fork to fix performance issues

* Fix issue with initial layout when page has a scrollbar

* Decrease polling interval (500ms is too slow)

* Rename file to match it's contents

* Added some notes and very minor fixes

* Fix Remove widget button (should be visible only in editing mode); fix widget actions menu

* Fixed missing grid markings

* Enhanced resize handle

* Updated placeholder color

* Render DashboardGrid only when dashboard is loaded
2019-05-16 15:43:46 +03:00
Levko Kravets
4508975749 [Bug fix] Plotly modebar appears above modals (#3799) 2019-05-15 10:43:34 +03:00
Gabriel Dutra
c76955be28 Refresh query when parameters update (#3737)
* Add touch state to parameters and autoupdate query

* Use values change event instead of $watch

* Remove getQueryResultDebounced

* Add Apply button

* Remove Input Number spinners for Parameters

* Make Apply Button optional

* Update share_embed_spec

* Change debounce to the Parameters component

* Remove unnecessary click on Execute query

* Add apply button to the remaining places

* Update dashboard_spec

* Use onKeyUp for InputNumber

* Simplify onParametersValuesChanged

* Update DateTime onChange function

* Don't apply when modifier key is pressed

* Remove refresh Button from Parameters

* Update apply button styling

* Update apply right distance

* Remove debounce for testing

* Use data-dirty instead of classNames for styling

* Make sure $apply runs before calling onChange
2019-05-15 08:57:06 +03:00
Gabriel Dutra
4f402379e8 Migrate Embed Query Dialog to React (#3783)
* Update Antd

* Migrate Embed Query Dialog to React

* Update jest ScheduleDialog snapshot

* Add Alert for unsafe queries

* Add CodeBlock

* Add inputs to change iframe size

* Undo ant update

* Update share embed spec

* Update styling

* Change border-radius to 2px

* Update margin between Public URL and IFrame Embed
2019-05-15 08:38:40 +03:00
Ran Byron
733b60102d Fixed visual-card alignment (#3795) 2019-05-14 14:10:35 -06:00
Levko Kravets
b9b30a39d2 [Bug fix] Edit parameter mapping: error when trying to change mapping type to Static; cannot change static value (#3800)
* Edit parameter mapping: error when trying to change mapping type to Static

* Parameter mapping editor: cannot change static value
2019-05-14 19:10:02 +03:00
Levko Kravets
c74d469181 resize-event: take into account transformations and transitions (#3794) 2019-05-13 22:51:05 +03:00
Ran Byron
95f11e6686 Loading indicator till app inits (#3788) 2019-05-13 12:11:22 -06:00
Jakdaw
ad6f7109de Fix support for calling MySQL Stored Procedures and allow queries to be cancelled (#3003)
* If MySQL returns multiple resultSets (eg when executing multiple statements, or calling stored procedures) then use the last resultSet that has columns

* Make cancellation of a MySQL query work (the same way the C client does it)

* Address code climate moans
2019-05-13 18:51:51 +03:00
taminif
b09ae46a9f filtered tag remove empty name at edit query (#3784)
* filtered tag remove empty name at edit query

* use filter
2019-05-13 17:57:45 +03:00
Arik Fraimovich
0cda0369f0 [BigQuery] Fix: in some queries there is no mode field (#3786)
Happened with INSERT/UPDATE queries.
2019-05-13 17:08:18 +03:00
Arik Fraimovich
50f11069ce Presto: ignore blank passwords (#3791)
PyHive expects only None as no password.
2019-05-13 12:36:07 +03:00
Arik Fraimovich
6bf764be07 Update query to bring only name to make sure screenshots are consistent. (#3790) 2019-05-12 20:43:32 +03:00
Ran Byron
3159410694 Restrict markdown image dimensions (#3789) 2019-05-12 20:23:10 +03:00
Gabriel Dutra
76bd2e3c50 Migrate Organization Settings to React (#3728)
* Migrate Organization Settings to React

* Fix failing spec and replace default values from inputs

* Add HelpTrigger and handleChange to SAML options

* Undo changes to ant-variables.less

* Add time format to OrganizationSettings
2019-05-12 14:23:22 +03:00
Omer Lachish
50a6f723b1 Fix Ability to Add Query-based Parameters to Existing Queries (#3716)
* propagate `isDirty` down to `QueryBasedParameterInput`

* go to /api/:id/dropdown while editing a query, since dropdown queries might still not be associated with the parent. see #3711

* show helpful error message if dropdown values cannot be fetched

* use backticks instead of line concatenation

* remove requirement to have direct access to dropdown query in order validate it. parent query association checks are sufficient

* remove isDirty-based implementation and allow dropdown queries through nested ACL even if they aren't associated yet (given that the user has _direct_ access to the dropdown query)

* fix tests to cover all cases for /api/queries/:id/dropdowns/:id

* fix indentation

* require access to the query, not the data source

* use require_access instead of has_access
2019-05-12 12:48:01 +03:00
Omer Lachish
0ee20797c8 Fix embeds without parameters (#3775)
* provide queryId when fetching query results in order to allow query-based api-key authentication to work properly

* cypress test to verify that embeds without parameters are shared succesfully

* rename Percy snapshot
2019-05-12 12:28:31 +03:00
taminif
d7515562a4 Fix: Filter empty tags (#3780) 2019-05-10 09:55:53 -06:00
Osmo Salomaa
feafbbe318 Avoid error with duplicate log lines (#3777)
https://docs.angularjs.org/error/ngRepeat/dupes
2019-05-07 22:07:39 +03:00
439 changed files with 16257 additions and 6986 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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).

View File

@@ -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

View File

@@ -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>
[![Documentation](https://img.shields.io/badge/docs-redash.io/help-brightgreen.svg)](https://redash.io/help/)
[![Datree](https://s3.amazonaws.com/catalog.static.datree.io/datree-badge-20px.svg)](https://datree.io/?src=badge)
[![Build Status](https://circleci.com/gh/getredash/redash.png?style=shield&circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040)](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
View 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).

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -4,4 +4,7 @@ module.exports = {
env: {
"jest/globals": true,
},
rules: {
"jest/no-focused-tests": "off",
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -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%;
}
}
}

View File

@@ -1,6 +1,5 @@
.alert {
padding-left: 30px;
padding-right: 30px;
padding: 15px;
span {
cursor: pointer;

View File

@@ -19,6 +19,12 @@
@font-size-base: 13px;
/* --------------------------------------------------------
Borders
-----------------------------------------------------------*/
@border-color-split: #f0f0f0;
/* --------------------------------------------------------
Typograpgy
-----------------------------------------------------------*/

View File

@@ -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;
}

View File

@@ -31,7 +31,7 @@
.collapsing,
.collapse.in {
padding: 5px 10px;
padding: 0;
transition: all 0.35s ease;
}

View File

@@ -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%);
}

View File

@@ -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
-----------------------------------------------------------*/

View File

@@ -154,3 +154,9 @@
Border Radius
-----------------------------------------------------------*/
.brd-2 { border-radius: 2px; }
/* --------------------------------------------------------
Alignment
-----------------------------------------------------------*/
.va-top { vertical-align: top; }

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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%;
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
/* --------------------------------------------------------

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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';

View 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;
}
}

View 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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View File

@@ -19,8 +19,6 @@
@import 'inc/ie-warning';
@import 'inc/flex';
@import 'redash/redash-newstyle';
html, body {
height: 100%;
margin: 0;

View 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);

View 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;
}
}

View 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;

View 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>
);
}
}

View 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;
}
}

View 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: '',
};

View File

@@ -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;

View 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: () => {},
};

View 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,
};

View 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;

View 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;
}

View 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;
}

View 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;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>;
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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>
);
})}

View File

@@ -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

View File

@@ -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;

View 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: '',
};

View 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;

View File

@@ -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);
}

View File

@@ -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;

View 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;
}
}
}

View 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;

View 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;
}
}
}

View File

@@ -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>

View File

@@ -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 {
&#123;&#123;&nbsp;&#125;&#125;
</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>

View File

@@ -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)}

View File

@@ -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)}
>

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 });

View File

@@ -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>

View 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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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}>

View 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;
}
}

View 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);

View 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;

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
.react-grid-layout {
&.disable-animations {
& > .react-grid-item {
transition: none !important;
}
}
}

View File

@@ -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">&times;</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>

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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} />);
}

View 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;

View 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;

View 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