Compare commits

...

100 Commits

Author SHA1 Message Date
restyled-io[bot]
9b3f31bdce Restyle Parameter feedback - #1 Server errors (#6226)
* Restyled by autopep8

* Restyled by black

* Restyled by clang-format

* Restyled by isort

* Restyled by prettier

* Restyled by reorder-python-imports

* Restyled by whitespace

* Restyled by yapf

---------

Co-authored-by: Restyled.io <commits@restyled.io>
2023-07-21 14:40:48 -05:00
Ran Byron
13e5500718 Parameter feedback - #2 Client errors in query page (#4319)
* Parameter feedback - #2 Client errors in query page

* Added cypress test

* Fixed percy screenshot

* Safer touched change

* Parameter feedback - #3 Added in Widgets (#4320)

* Parameter feedback - #3 Added in Widgets

* Added cypress tests

* Making sure widget-level param is selected

* Parameter feedback - #4 Added in Dashboard params (#4321)

* Parameter feedback - #4 Added in Dashboard params

* Added cypress test

* Moved to service

* Parameter feedback - #5 Unsaved indication (#4322)

* Parameter feedback - #5 Unsaved indication

* Added ANGULAR_REMOVE_ME

* Added cypress test

* Fixed percy screenshot

* Some code improvements

* Parameter input feedback - #6 Better value normalization (#4327)
2023-07-21 14:36:53 -05:00
Ran Byron
5213b524b4 Sorting param names in error msgs 2019-10-31 08:41:05 +02:00
Ran Byron
e20b2b5dd3 pep8 2019-10-29 22:08:58 +02:00
Ran Byron
ac77587335 Added unit tests 2019-10-29 22:01:41 +02:00
Ran Byron
c553f006d9 Parameter input feedback - server only 2019-10-29 22:01:33 +02:00
Arik Fraimovich
88ae639ee4 CircleCI workflow improvements (#4296)
* CircleCI workflow improvements

- Don't automatically build the Docker image.
- Make the Python lint step requirement for the follow up steps. When it fails it usually means there is a code error which will prevent the next steps anyway.

* Fix YAML syntax error.

* Add separate build Docker image step for master branch
2019-10-27 22:27:34 +02:00
Omer Lachish
ba413c210e use rq_redis_connection instead of redis_connection (#4288) 2019-10-25 14:23:24 +03:00
Levko Kravets
7157244eec Migrate Table visualization to React Part 2: Editor (#4175)
* Migrate table editor to React: skeleton, Grid tab

* Columns tab

* Cleanup

* Columns tab: DnD column sorting

* Columns types should be JSX

* New Columns tab UI/X

* Use Sortable component on Columns tab

* Tests: Grid Settings

* Tests: Columns Settings

* Tests: Editors for Text, Number, Boolean and Date/Time columns

* Tests: Editors for Image and Link columns

* Minor UI fix

* Trigger build

* Debounce inputs
2019-10-24 12:46:46 +03:00
Gabriel Dutra
9f7844640a Introduce inheritance to the Parameter structure (#4049)
* Start draft for new Parameter structure

* Add the rest of the methods

* EnumParameter

* QueryBasedDropdownParameter

* DateParameter

* DateRangeParameter

* Update Parameter usage on code

* Merge dynamicValue into normalizedValue

* Add updateLocals and omit unwanted props

* Allow null NumberParameter and omit parentQueryId

* Rename parameter getValue to getExecutionValue

* Update $$value to normalizedValue + omit on save

* Add a few comments

* Remove ngModel property from Parameter

* Use value directly in DateRangeParameter

* Use simpler separator for DateRange url param

* Add backward compatibility

* Use normalizeValue null value for isEmpty

* Start creating jest tests

* Add more tests

* Normalize null value for multi mode in Enum

* Use saved value for param isEmpty
2019-10-24 12:42:30 +03:00
Nicolas Le Manchet
246eca1121 Migrate the application to Python 3 (#4251)
* Make core app compatible with Python 3

No backward compatibility with Python 2.7 is kept.
This commit mostly contains changes made with 2to3 and manual
tweaking when necessary.

* Use Python 3.7 as base docker image

Since it is not possible to change redash/base:debian to Python 3
without breaking future relases, its Dockerfile is temporarly
copied here.

* Upgrade some requirements to newest versions

Some of the older versions were not compatible with Python 3.

* Migrate tests to Python 3

* Build frontend on Python 3

* Make the HMAC sign function compatible with Python 3

In Python 3, HMAC only works with bytes so the strings and the
float used in the sign function need to be encoded.
Hopefully this is still backward compatible with already generated
signatures.

* Use assertCountEqual instead of assertItemsEqual

The latter is not available in Python 3.
See https://bugs.python.org/issue17866

* Remove redundant encoding header for Python 3 modules

* Remove redundant string encoding in CLI

* Rename list() functions in CLI

These functions shadow the builtin list function which is
problematic since 2to3 adds a fair amount of calls to the builtin
list when it finds dict.keys() and dict.values().

Only the Python function is renamed, from the perspective of the
CLI nothing changes.

* Replace usage of Exception.message in CLI

`message` is not available anymore, instead use the string
representation of the exception.

* Adapt test handlers to Python 3

* Fix test that relied on dict ordering

* Make sure test results are always uploaded (#4215)

* Support encoding memoryview to JSON

psycopg2 returns `buffer` objects in Python 2.7 and `memoryview`
in Python 3. See #3156

* Fix test relying on object address ordering

* Decode bytes returned from Redis

* Stop using e.message for most exceptions

Exception.message is not available in Python 3 anymore, except
for some exceptions defined by third-party libraries.

* Fix writing XLSX files in Python 3

The buffer for the file should be made of bytes and the actual
content written to it strings.

Note: I do not know why the diff is so large as it's only a two
lines change. Probably a white space or file encoding issue.

* Fix test by comparing strings to strings

* Fix another exception message unavailable in Python 3

* Fix export to CSV in Python 3

The UnicodeWriter is not used anymore. In Python 3, the interface
provided by the CSV module only deals with strings, in and out.
The encoding of the output is left to the user, in our case
it is given to Flask via `make_response`.

* (Python 3) Use Redis' decode_responses=True option (#4232)

* Fix test_outdated_queries_works_scheduled_queries_tracker (use utcnow)

* Make sure Redis connection uses decoded_responses option

* Remove unused imports.

* Use Redis' decode_responses option

* Remove cases of explicit Redis decoding

* Rename helper function and make sure it doesn't apply twice.

* Don't add decode_responses to Celery Redis connection URL

* Fix displaying error while connecting to SQLite

The exception message is always a string in Python 3, so no
need to try to decode things.

* Fix another missing exception message

* Handle JSON encoding for datasources returning bytes

SimpleJSON assumes the bytes it receives contain text data, so it
tries to UTF-8 encode them. It is sometimes not true, for instance
the SQLite datasource returns bytes for BLOB types, which typically
do not contain text but truly binary data.

This commit disables SimpleJSON auto encoding of bytes to str and
instead uses the same method as for memoryviews: generating a
hex representation of the data.

* Fix Python 3 compatibility with RQ

* Revert some changes 2to3 tends to do (#4261)

- Revert some changes 2to3 tends to do when it errs on the side of caution regarding dict view objects.

- Also fixed some naming issues with one character variables in list comprehensions.

- Fix Flask warning.

* Upgrade dependencies

* Remove useless `iter` added by 2to3

* Fix get_next_path tests (#4280)

* Removed setting SERVER_NAME in tests setup to avoid a warning.

* Change get_next_path to not return empty string in case of a domain only value.

* Fix redirect tests:

Since version 0.15 of Werkzeug it uses full path for fixing the location header instead of the root path.

* Remove explicit dependency for Werkzeug

* Switched pytz and certifi to unbinded versions.

* Switch to new library for getting country from IP

`python-geoip-geolite2` is not compatible with Python 3, instead
use `maxminddb-geolite2` which is very similar as it includes
the geolite2 database in the package .

* Python 3 RQ modifications (#4281)

* show current worker job (alongside with minor cosmetic column tweaks)

* avoid loading entire job data for queued jobs

* track general RQ queues (default, periodic and schemas)

* get all active RQ queues

* call get_celery_queues in another place

* merge dicts the Python 3 way

* extend the result_ttl of refresh_queries to 600 seconds to allow it to continue running periodically even after longer executions

* Remove legacy Python flake8 tests
2019-10-24 12:42:13 +03:00
Arik Fraimovich
7ffb97232e Pin Cypress version (#4284) 2019-10-24 12:22:56 +03:00
Omer Lachish
8b9fa53efe extend the result_ttl of refresh_queries to 600 seconds to allow it to continue running periodically even after longer executions (#4283) 2019-10-24 11:56:07 +03:00
Omer Lachish
43b35b6fb4 Monitor general RQ queues (default, periodic and schemas) (#4256)
* track general RQ queues (default, periodic and schemas)

* get all active RQ queues

* call get_celery_queues in another place
2019-10-23 12:31:32 +03:00
Omer Lachish
f0f85ece42 avoid loading entire job data for queued jobs (#4257) 2019-10-23 11:43:31 +03:00
Omer Lachish
612833404b show current worker job (alongside with minor cosmetic column tweaks) (#4262) 2019-10-23 11:20:15 +03:00
Ran Byron
5d58503623 Minor alert bug fixes (#4274) 2019-10-22 16:58:20 +03:00
Ran Byron
3dfad87266 Extracted alert menu button (#4273) 2019-10-22 13:00:16 +03:00
Levko Kravets
0659ef1079 Add "use-debounce" dependency (#4268) 2019-10-19 22:28:57 +03:00
Ran Byron
a2e21dd1c3 App Header React migration (#4245) 2019-10-19 14:25:58 +03:00
Levko Kravets
f165cad9ff Migrate Sunburst Renderer to React (#4259) 2019-10-17 19:16:05 +03:00
Levko Kravets
e0a2705c1a Restore <body> bottom padding (#4252) 2019-10-17 13:22:09 +03:00
Levko Kravets
0aca649cb5 Migrate Sankey renderer to React (#4255) 2019-10-17 13:19:29 +03:00
Stefan Maric
79b37e8843 Fix double-scrollbar when in fullscreen (#4243) 2019-10-16 23:46:44 +03:00
Ran Byron
72bb5d29a0 Fix: Alert page breaks when target query returns null result (#4250)
* Fix: Alert page breaks when target query returns null result

* Better handling of topValue value
2019-10-16 11:27:37 +03:00
Omer Lachish
5a5fdecdde Replace Celery with RQ (except for execute_query tasks) (#4093)
* add rq and an rq_worker service

* add rq_scheduler and an rq_scheduler service

* move beat schedule to periodic_jobs queue

* move version checks to RQ

* move query result cleanup to RQ

* use timedelta and DRY up a bit

* move custom tasks to RQ

* do actual schema refreshes in rq

* rename 'period_jobs' to 'periodic', as it obviously holds jobs

* move send_email to rq

* DRY up enqueues

* ditch  and use a partially applied  decorator

* move subscribe to rq

* move check_alerts_for_query to rq

* move record_event to rq

* make tests play nicely with rq

* 👋 beat

* rename rq_scheduler to plain scheduler, now that there's no Celery scheduler entrypoint

* add some color to rq-worker's output

* add logging context to rq jobs (while keeping execute_query context via get_task_logger for now)

* move schedule to its own module

* cancel previously scheduled periodic jobs. not sure this is a good idea.

* rename redash.scheduler to redash.schedule

* allow custom dynamic jobs to be added decleratively

* add basic monitoring to rq queues

* add worker monitoring

* pleasing the CodeClimate overlords

* adjust cypress docker-compose.yml to include rq changes

* DRY up Cypress docker-compose

* add rq dependencies to cypress docker-compose service

* an odd attempt at watching docker-compose logs when running with Cypress

* Revert "an odd attempt at watching docker-compose logs when running with Cypress"

This reverts commit 016bd1a93e.

* show docker-compose logs at Cypress shutdown

* Revert "DRY up Cypress docker-compose"

This reverts commit 43abac7084.

* minimal version for binding is 3.2

* remove unneccesary code reloads on cypress

* add a  command which errors if any of the workers running inside the current machine haven't been active in the last minute

* SCHEMAS_REFRESH_QUEUE is no longer a required setting

* split tasks/queries.py to execution.py and maintenance.py

* fix tests after query execution split

* pleasing the CodeClimate overlords

* rename worker to celery_worker and rq_worker to worker

* use /rq_status instead of /jobs

* show started jobs' time ago according to UTC

* replace all spaces in column names

* fix query tests after execution split

* exit with an int

* general lint

* add an entrypoint for rq_healthcheck

* fix indentation

* delete all existing periodic jobs before scheduling them

* remove some unrequired requires

* move schedule example to redash.schedule

* add RQ integration to Sentry's setup

* pleasing the CodeClimate overlords

* remove replication settings from docker-compose - a proper way to scale using docker-compose would be the --scale CLI option, which will be described in the knowledge based

* revert to calling a function in dynamic settings to allow periodic jobs to be scheduled after app has been loaded

* don't need to depend on context when templating failure reports

* set the timeout_ttl to double the interval to avoid job results from expiring and having periodic jobs not reschedule

* whoops, bad merge

* describe custom jobs and don't actually schedule them

* fix merge
2019-10-15 23:59:22 +03:00
Omer Lachish
f6e1470a7c Avoid depending on app context when templating failure reports (#4231)
* don't need to depend on context when templating failure reports

* extract a render_template function with some docs

* CodeClimate has really outdone itself this time. Removed a whitespace character in order to fix 2 CodeClimate errors

* apparently whitespace doesn't count as a character
2019-10-15 23:08:28 +03:00
Arik Fraimovich
27cd76797e Make sure query results are consistent (#4246) 2019-10-15 21:48:44 +03:00
Amol Grover
29b113005c pagerduty.py: Change default summary text (#4239)
* pagerduty.py: Change default summary text

Change is made to use alert name in PagerDuty's alert
destination default summary text instead of
query id and name

* Update default description text & field description
2019-10-15 11:34:34 +03:00
Ran Byron
53d971bf87 Implemented new condition comparison options (#4240)
* Implemented new condition comparison options

* Fixed test

* Move backward compatibility code to service
2019-10-15 08:41:23 +03:00
Gabriel Dutra
a102e93e50 Fix dashboard parameter mapping issues (#4211) 2019-10-14 17:21:44 -03:00
Ran Byron
74beed80d2 Fixed hangouts chat icon (#4236) 2019-10-11 13:11:15 +03:00
Ran Byron
39f038f992 Fixed alert destination hrefs (#4235)
* Fixed alert destination hrefs

* Added query url
2019-10-11 13:10:47 +03:00
Gabriel Dutra
da2ed56281 Extend bolder markdown fix to widget description (#4229) 2019-10-10 09:53:58 -03:00
Arik Fraimovich
9d8812a598 Postgres: make sure table from the public schema doesn't get merged with table from other schemas (#4224)
* Postgres: make sure table from the public schema doesn't get merged with a table from another schema.

* PEP8 updates
2019-10-10 13:02:22 +03:00
Arik Fraimovich
204447a9f5 Add interface to abstract query result persistence (#4147)
* Add interface to implement custom persistence for QueryResult data

Co-authored-by: Omer Lachish <omer@rauchy.net>

* Deserialize query results data in the model

* Change order of mixins.

* Make DBPersistence.data setter in sycn with getter + tests
2019-10-10 10:39:55 +03:00
Arik Fraimovich
3b7efb8c1f Make sure that the default settings signal that no email server is configured (#4226)
* The sender email address has to be None for the test of "is email server
configured" to be correct. Moved the dev setting into docker-compose.yml.

* Move the REDASH_MAIL_SERVER setting into docker-compose.yml to revert the default value to its original value in case anyone was using it.

* Make worker dependant on email as it's the one that actually using it.
2019-10-07 22:23:22 +03:00
Ran Byron
69dc761c60 Alert page - migrate to React and redesign (#4153) 2019-10-07 19:15:06 +03:00
Ran Byron
2f42b8154c Fix: Misleading warning when trying to download results of unsaved query #4218 (#4219) 2019-10-06 16:00:24 +03:00
Arik Fraimovich
3f9d49dbd1 Remove debug code (#4216) 2019-10-06 11:57:05 +03:00
Justin Clift
0a5dca5d72 Adjust botocore dependency, so we don't need to update it as often (#4154) 2019-10-06 11:47:16 +03:00
Gabriel Dutra
8ea285dda9 Split setup in advanced and regular for data sources and destinations (#4160)
* DynamicForm support for advanced options

* Randomly select a few options to be advanced

* Merge conditions with the same logic

* Address some comments

* Update styling for the button

* Some style adjustments (#4162)

* Don't set default value to additional settings

* Rename advanced -> extra

* Show extra fields by default when they are filled

* Update hasFilledExtraField logic

* Add example field from destination as extra
2019-10-06 11:46:50 +03:00
Gabriel Dutra
569c325aa0 Support for dropdown of predefined options in data sources setup (#4161)
* Support for predefined options in data sources

* DynamicForm Select: title -> name

* Make it work with "enum" prop

* Make it work for "extendedEnum" prop

* Not JS

* Deep copy the configuration schema
2019-10-06 11:44:56 +03:00
Gabriel Dutra
d8a0af1a95 Fix query based dropdown not adding quote marks correctly (#4186)
* Handle non-array in multi-value QueryBasedParameter

* Use state value in QueryBasedParameterInput

* Normalize array in parameter structure

* Add Multi-selection test

* Remove unnecessary not null check
2019-10-06 11:35:47 +03:00
Ran Byron
648847df0b Fix: Multi-value Dropdown not available in Static Value edit dialog (#4213) 2019-10-05 17:52:50 +03:00
Arik Fraimovich
3f31bf3fc0 Fix: use correct variable name (#4210) 2019-10-03 13:00:21 +03:00
Rui Z
f6ad3d9d24 Vertica: prevent overwriting row data when duplicated column names exist (#4201)
* Vertica: prevent overwriting row data when duplicated column names exist

* remove enumeration
2019-10-02 12:20:51 +03:00
bennywij
e8ccdc23c7 Correct typo in log stmt. Add comment re PR 4201 (#4205) 2019-10-02 11:37:57 +03:00
Levko Kravets
a8af968d70 Sortable component (#4199) 2019-09-30 19:12:27 +03:00
Jesse
cb14459881 Fixes #3766. (#4189) 2019-09-27 11:00:50 +02:00
Gabriel Dutra
780fbceba5 Fix Pivot Visualization should not be saving data (#4174) 2019-09-25 11:36:39 -03:00
Omer Lachish
2c77c219c6 Add maildev to the dev stack (#4173)
* add maildev to the dev stack

* Update redash/settings/__init__.py

Co-Authored-By: Arik Fraimovich <arik@arikfr.com>
2019-09-25 10:50:34 +03:00
Gabriel Dutra
874e0d1ce3 Fix Execute Selected not working for dirty queries (#4176) 2019-09-24 10:59:40 -03:00
Gabriel Dutra
401d164622 Remove Widget dev console errors (#4177) 2019-09-24 07:39:03 -03:00
Arik Fraimovich
ff041b77cf Update Sentry-SDK (#4169) 2019-09-23 09:54:10 +03:00
Arik Fraimovich
b2d1636f8e Downgrade mysqlclient to 1.3.14 (#4165)
Closes #4164.
2019-09-22 14:56:09 +03:00
Arik Fraimovich
a3e8477410 List enabled Query Runner types during build (#4166)
* Add CLI command to list enabled query runner types
2019-09-22 14:55:21 +03:00
Omer Lachish
ed22b63f22 remove the annoying quoted title from EmptyState (#4168) 2019-09-22 14:33:19 +03:00
Arik Fraimovich
d636b29ba9 Update version (#4167) 2019-09-22 13:23:56 +03:00
Ran Byron
6173a2a619 Handle Create Dashboard with middle click (#4158) 2019-09-22 10:57:28 +03:00
Gabriel Dutra
fd435d2182 Migrate Pivot Table visualization to React (#4133)
* npm install react-pivottable

* Initiate Pivot Table Migration

* Update renderer with editor options

* Clean up

* Remove old pivottable from package.json

* Test Percy Snapshot with Pivot Table in a Dashboard

* Tmp: use cy.wait to make sure dashboard is loaded

* Clean up Percy snapshot test

* Small improvements
- cy.all with multiple args
- add controls to pivot valid options

* Watch for options in the Renderer
2019-09-22 10:46:03 +03:00
Gabriel Dutra
cb654b3f21 Migrate Widget component to React (#4020)
* Improve sizing for Number inputs

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

* Migrate WidgetDialog

* Start migrating Widget

* Update textbox to use HtmlContent

* QueryLink migration and some updates

* Add visualization rendering

* Render widget

* Add delete button

* Update AutoHeight

* Add widget bottom

* Add Drodpown button

* Split Widget component

* Update with #4056 and trigger netlify

* In progress: use composition

* Add header and footer

* Update widget actions positioning

* Re-render when refreshing from widget

* Add workaround to force DashboardGrid re-render

* VisualizationWidgetFooter component

* VisualizationWidget menu

* Separate RestrictedWidget

* Update tests

* Update margin for Parameters

* Remove widget files

* Revert "Improve sizing for Number inputs"

This reverts commit a02ce8f0aa.

* Some cleanup

* Move refresh logic to the Dashboard

* Add loadingWidgets logic to the public dashboard

* Add onLoadWidget

* Remove parameter from URL when empty

* Recreate widget array instead of loadingWidgets

* Add comment about re-rendering + whitespace missing

* CR changes

* Use plain html instead of string syntax

Co-Authored-By: Ran Byron <ranbena@gmail.com>
2019-09-20 22:08:42 +03:00
Arik Fraimovich
e8d40bbdac CHANGELOG for v8.0.0-beta.2 (#4145)
* Stop building tarballs.

* Update version reference.

* CHANGELOG for 8.0.0-beta.2
2019-09-18 11:23:32 +03:00
Levko Kravets
e5d52055d9 Widget filters overlapped by visualization (#4137)
* Fix: widget filters overlapped by visualization

* Fix tests

* Fix tests
2019-09-18 11:22:26 +03:00
Levko Kravets
c5e414e6ba Color picker component (#4136) 2019-09-16 13:01:48 +03:00
Gabriel Dutra
b9a40d1808 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-09-16 10:00:23 +03:00
Ran Byron
033dd0d15e Bug fix: Query view doesn't sync parameters when selecting and deleting (#4146) 2019-09-16 07:15:38 +03:00
Arik Fraimovich
95795d93c7 CHANGELOG for V8-beta. (#4057)
* CHANGELOG for V8-beta.

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2019-09-15 15:48:59 +03:00
Arik Fraimovich
75e48b0bd6 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-09-15 15:18:48 +03:00
Levko Kravets
75883a1a02 Counter Editor: move components to own files (#4138) 2019-09-13 22:35:19 +03:00
Gabriel Dutra
75a5546741 Add jsconfig settings with '@' webpack alias (#4135) 2019-09-12 18:25:40 -03:00
Levko Kravets
54071e4b87 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-09-12 10:23:43 +03:00
Arik Fraimovich
6458a1eb62 Remove duplicate messages method (#4131) 2019-09-11 11:56:40 +03:00
Levko Kravets
ecf160c9bc Alerts: Add more condition comparison options (#4134)
* getredash/redash#4132 Add more condition comparison options

* Add arguments to fallback lambda
2019-09-11 11:18:59 +03:00
Levko Kravets
2c98f0425d 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-09-09 13:00:26 +03:00
Ran Byron
8f01988c8c Decrease size of widget pagination (#4120)
* Added tests

* Perhaps this would trigger percy

* Decrease size of widget pagination

* Removed unused attr

* Updated tests
2019-09-09 10:57:26 +03:00
Arik Fraimovich
b8741f6cff Sync botocor eversions across requirements files. (#4128) 2019-09-09 10:44:05 +03:00
Levko Kravets
424751d9e9 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-09-09 10:10:10 +03:00
Arik Fraimovich
e048a69392 Upgrade Sentry-SDK and enable additional integratoins (#4127)
* Update sentry-sdk version

* Add additional Sentry integrations
2019-09-09 10:00:09 +03:00
Ran Byron
2c1e846837 Widget table scroll-x visible (#4101)
* Table viz horizontal scroll made visible

* Added tests

* Fixed snapshot pre-condition

* Perhaps this would trigger percy
2019-09-09 09:50:03 +03:00
Justin Clift
4b9e26de5a Update botocore, to get pass pip warning (#4122) 2019-09-04 09:12:22 +03:00
sphenlee
17f50192e7 hive_ds: show a user friendly error message when possible (#4121) 2019-09-04 08:10:56 +03:00
Gabriel Dutra
dcdec0abb5 Use ng-src for data source icons (#4123) 2019-09-03 20:42:19 +03:00
Ran Byron
302c6dd02e Fix number param value normlization (#4116) 2019-09-02 16:29:56 +03:00
Arik Fraimovich
4c56900248 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-09-02 16:01:05 +03:00
swfz
1f1f853297 Display data source icon in query editor (#4119) 2019-09-02 14:42:41 +03:00
Arik Fraimovich
43f63b1b57 Add ability to use Ant's Table loading property when using ItemsTable (#4117) 2019-09-02 14:38:28 +03:00
Gabriel Dutra
5ae80835b1 Fix Dropdown parameter options appearing behind Dialog (#4109) 2019-09-01 21:55:37 -03:00
Arik Fraimovich
df3da82afd 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-09-01 22:17:53 +03:00
Ran Byron
98e33b7780 Fix widget bottom element alignment (#4110) 2019-09-01 16:59:11 +03:00
Omer Lachish
8a3f6f90eb 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-09-01 10:50:14 +03:00
shinsuke-nara
cab011def9 Migrate with SQL statements. (#4105) 2019-08-30 14:08:22 +03:00
Omer Lachish
31c888ea8e 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-08-30 07:03:51 +03:00
Sandeep Belagavi
443054428f [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-08-29 19:22:52 +03:00
Gleb Lesnikov
ef9a4d5eed [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-08-26 10:17:49 +03:00
Arik Fraimovich
a2b68a3569 Make sure we always pass a list to _get_column_lists (#4095)
(some data sources might return None as the columns list)
2019-08-25 17:39:15 +03:00
Ran Byron
e7b707eb25 Removed redash-newstyle.less (#4017) 2019-08-22 08:06:54 +03:00
Arik Fraimovich
1786273344 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-08-21 14:31:17 +03:00
Christian Clauss
d38ca803c5 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-08-18 11:27:44 +03:00
Gabriel Dutra
a1f11cb8d9 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-08-18 11:27:20 +03:00
463 changed files with 18018 additions and 8697 deletions

View File

@@ -1,18 +1,24 @@
version: 2.0
flake8-steps: &steps
- checkout
- run: sudo pip install flake8
- run: ./bin/flake8_tests.sh
build-docker-image-job: &build-docker-image-job
docker:
- image: circleci/node:8
steps:
- setup_remote_docker
- checkout
- run: sudo apt install python3-pip
- run: sudo pip3 install -r requirements_bundles.txt
- run: .circleci/update_version
- run: npm run bundle
- run: .circleci/docker_build
jobs:
python-flake8-tests:
backend-lint:
docker:
- image: circleci/python:3.7.0
steps: *steps
legacy-python-flake8-tests:
docker:
- image: circleci/python:2.7.15
steps: *steps
steps:
- checkout
- run: sudo pip install flake8
- run: ./bin/flake8_tests.sh
backend-unit-tests:
environment:
COMPOSE_FILE: .circleci/docker-compose.circle.yml
@@ -32,6 +38,9 @@ jobs:
- run:
name: Create Test Database
command: docker-compose run --rm postgres psql -h postgres -U postgres -c "create database tests;"
- run:
name: List Enabled Query Runners
command: docker-compose run --rm redash manage ds list_types
- run:
name: Run Tests
command: docker-compose run --name tests redash tests --junitxml=junit.xml --cov-report xml --cov=redash --cov-config .coveragerc tests/
@@ -41,6 +50,7 @@ jobs:
mkdir -p /tmp/test-results/unit-tests
docker cp tests:/app/coverage.xml ./coverage.xml
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
when: always
- store_test_results:
path: /tmp/test-results
- store_artifacts:
@@ -60,8 +70,8 @@ jobs:
- image: circleci/node:8
steps:
- checkout
- run: sudo apt install python-pip
- run: sudo pip install -r requirements_bundles.txt
- run: sudo apt install python3-pip
- run: sudo pip3 install -r requirements_bundles.txt
- run: npm install
- run: npm run bundle
- run: npm test
@@ -90,47 +100,25 @@ jobs:
- run:
name: Execute Cypress tests
command: npm run cypress run-ci
build-tarball:
docker:
- image: circleci/node:8
steps:
- checkout
- run: sudo apt install python-pip
- run: sudo pip install -r requirements_bundles.txt
- run: npm install
- run: .circleci/update_version
- run: npm run bundle
- run: npm run build
- run: rm -rf ./node_modules/
- run: .circleci/pack
- store_artifacts:
path: /tmp/artifacts/
build-docker-image:
docker:
- image: circleci/node:8
steps:
- setup_remote_docker
- checkout
- run: sudo apt install python-pip
- run: sudo pip install -r requirements_bundles.txt
- run: .circleci/update_version
- run: npm run bundle
- run: .circleci/docker_build
build-docker-image: *build-docker-image-job
build-preview-docker-image: *build-docker-image-job
workflows:
version: 2
build:
jobs:
- python-flake8-tests
- legacy-python-flake8-tests
- backend-unit-tests
- backend-lint
- backend-unit-tests:
requires:
- backend-lint
- frontend-lint
- frontend-unit-tests:
requires:
- backend-lint
- frontend-lint
- frontend-e2e-tests:
requires:
- frontend-lint
- build-tarball:
- build-preview-docker-image:
requires:
- backend-unit-tests
- frontend-unit-tests
@@ -139,15 +127,16 @@ workflows:
branches:
only:
- master
- hold:
type: approval
requires:
- backend-unit-tests
- frontend-unit-tests
- frontend-e2e-tests
filters:
branches:
only:
- /release\/.*/
- build-docker-image:
requires:
- backend-unit-tests
- frontend-unit-tests
- frontend-e2e-tests
filters:
branches:
only:
- master
- preview-image
- /release\/.*/
- hold

View File

@@ -14,9 +14,16 @@ services:
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
REDASH_RATELIMIT_ENABLED: "false"
worker:
scheduler:
build: ../
command: scheduler
depends_on:
- server
environment:
REDASH_REDIS_URL: "redis://redis:6379/0"
worker:
build: ../
command: worker
depends_on:
- server
environment:
@@ -24,7 +31,18 @@ services:
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
QUEUES: "queries,scheduled_queries,celery,schemas"
QUEUES: "default periodic schemas"
celery_worker:
build: ../
command: celery_worker
depends_on:
- server
environment:
PYTHONUNBUFFERED: 0
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
QUEUES: "queries,scheduled_queries"
WORKERS_COUNT: 2
cypress:
build:
@@ -32,7 +50,9 @@ services:
dockerfile: .circleci/Dockerfile.cypress
depends_on:
- server
- celery_worker
- worker
- scheduler
environment:
CYPRESS_baseUrl: "http://server:5000"
PERCY_TOKEN: ${PERCY_TOKEN}

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

@@ -8,11 +8,40 @@ COPY client /frontend/client
COPY webpack.config.js /frontend/
RUN npm run build
FROM redash/base:debian
FROM python:3.7-slim
EXPOSE 5000
# Controls whether to install extra dependencies needed for all data sources.
ARG skip_ds_deps
RUN useradd --create-home redash
# Ubuntu packages
RUN apt-get update && \
apt-get install -y \
curl \
gnupg \
build-essential \
pwgen \
libffi-dev \
sudo \
git-core \
wget \
# Postgres client
libpq-dev \
# for SAML
xmlsec1 \
# Additional packages required for data sources:
libssl-dev \
default-libmysqlclient-dev \
freetds-dev \
libsasl2-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# We first copy only the requirements file, to avoid rebuilding on every file
# change.
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./

View File

@@ -4,7 +4,7 @@
[![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?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040)
[![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.

View File

@@ -1,9 +1,8 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#!/usr/bin/env python3
"""Copy bundle extension files to the client/app/extension directory"""
import logging
import os
from pathlib2 import Path
from pathlib import Path
from shutil import copy
from collections import OrderedDict as odict

View File

@@ -1,9 +1,9 @@
#!/bin/bash
set -e
worker() {
celery_worker() {
WORKERS_COUNT=${WORKERS_COUNT:-2}
QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
QUEUES=${QUEUES:-queries,scheduled_queries}
WORKER_EXTRA_OPTIONS=${WORKER_EXTRA_OPTIONS:-}
echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..."
@@ -11,23 +11,36 @@ worker() {
}
scheduler() {
WORKERS_COUNT=${WORKERS_COUNT:-1}
QUEUES=${QUEUES:-celery}
SCHEDULE_DB=${SCHEDULE_DB:-celerybeat-schedule}
echo "Starting RQ scheduler..."
echo "Starting scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
exec /app/manage.py rq scheduler
}
exec /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
dev_scheduler() {
echo "Starting dev RQ scheduler..."
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq scheduler
}
worker() {
echo "Starting RQ worker..."
exec /app/manage.py rq worker $QUEUES
}
dev_worker() {
echo "Starting dev RQ worker..."
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq worker $QUEUES
}
dev_celery_worker() {
WORKERS_COUNT=${WORKERS_COUNT:-2}
QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
SCHEDULE_DB=${SCHEDULE_DB:-celerybeat-schedule}
QUEUES=${QUEUES:-queries,scheduled_queries}
echo "Starting dev scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..."
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- /usr/local/bin/celery worker --app=redash.worker --beat -s$SCHEDULE_DB -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- /usr/local/bin/celery worker --app=redash.worker -c$WORKERS_COUNT -Q$QUEUES -linfo --max-tasks-per-child=10 -Ofair
}
server() {
@@ -45,6 +58,10 @@ celery_healthcheck() {
exec /usr/local/bin/celery inspect ping --app=redash.worker -d celery@$HOSTNAME
}
rq_healthcheck() {
exec /app/manage.py rq healthcheck
}
help() {
echo "Redash Docker."
echo ""
@@ -52,10 +69,14 @@ help() {
echo ""
echo "server -- start Redash server (with gunicorn)"
echo "worker -- start Celery worker"
echo "scheduler -- start Celery worker with a beat (scheduler) process"
echo "dev_worker -- start Celery worker with a beat (scheduler) process which picks up code changes and reloads"
echo "celery_worker -- start Celery worker"
echo "dev_celery_worker -- start Celery worker process which picks up code changes and reloads"
echo "worker -- start a single RQ worker"
echo "dev_worker -- start a single RQ worker with code reloading"
echo "scheduler -- start an rq-scheduler instance"
echo "dev_scheduler -- start an rq-scheduler instance with code reloading"
echo "celery_healthcheck -- runs a Celery healthcheck. Useful for Docker's HEALTHCHECK mechanism."
echo "rq_healthcheck -- runs a RQ healthcheck that verifies that all local workers are active. Useful for Docker's HEALTHCHECK mechanism."
echo ""
echo "shell -- open shell"
echo "dev_server -- start Flask development server with debugger and auto reload"
@@ -89,10 +110,30 @@ case "$1" in
shift
scheduler
;;
dev_scheduler)
shift
dev_scheduler
;;
celery_worker)
shift
celery_worker
;;
dev_celery_worker)
shift
dev_celery_worker
;;
dev_worker)
shift
dev_worker
;;
rq_healthcheck)
shift
rq_healthcheck
;;
celery_healthcheck)
shift
celery_healthcheck
;;
dev_server)
export FLASK_DEBUG=1
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0

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,9 +1,10 @@
#!/bin/env python
from __future__ import print_function
#!/bin/env python3
import sys
import re
import subprocess
def get_change_log(previous_sha):
args = ['git', '--no-pager', 'log', '--merges', '--grep', 'Merge pull request', '--pretty=format:"%h|%s|%b|%p"', 'master...{}'.format(previous_sha)]
log = subprocess.check_output(args)
@@ -33,4 +34,4 @@ if __name__ == '__main__':
changes = get_change_log(previous_sha)
for change in changes:
print(change)
print(change)

View File

@@ -1,4 +1,4 @@
from __future__ import print_function
#!/usr/bin/env python3
import os
import sys
import re

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
import urllib
import argparse
import os
@@ -27,7 +27,7 @@ def run(cmd, cwd=None):
def confirm(question):
reply = str(raw_input(question + ' (y/n): ')).lower().strip()
reply = str(input(question + ' (y/n): ')).lower().strip()
if reply[0] == 'y':
return True

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -47,6 +47,7 @@
@zindex-dropdown: 2050;
@zindex-picker: 2050;
@zindex-tooltip: 2060;
@item-hover-bg: #e5f8ff;
.@{drawer-prefix-cls} {
&.help-drawer {
@@ -60,6 +61,11 @@
font-weight: normal;
}
.ant-select-dropdown-menu-item em {
color: @input-color-placeholder;
font-size: 11px;
}
// Fix for disabled button styles inside Tooltip component.
// Tooltip wraps disabled buttons with `<span>` and moves all styles
// and classes to that `<span>`. This resets all button styles and
@@ -77,12 +83,6 @@
}
}
// Fix for Ant dropdowns when they are used in Boootstrap modals
// ANGULAR_REMOVE_ME Remove when all dialogs will be migrated to React (also search and remove usages)
.ant-dropdown-in-bootstrap-modal {
z-index: 1050;
}
// Button overrides
.@{btn-prefix-cls} {
transition-duration: 150ms;
@@ -156,6 +156,10 @@
border-color: transparent;
color: @pagination-color;
line-height: @pagination-item-size - 2px;
.@{pagination-prefix-cls}.mini & {
line-height: @pagination-item-size-sm - 2px;
}
}
&:focus .@{pagination-prefix-cls}-item-link,
@@ -322,7 +326,7 @@
}
.@{btn-prefix-cls} .@{iconfont-css-prefix}-ellipsis {
margin: 0 -7px;
margin: 0 -7px 0 -8px;
}
// Collapse
@@ -364,4 +368,10 @@
border-radius: 50%;
}
}
// for form items that contain text
&.form-item-line-height-normal .@{form-prefix-cls}-item-control {
line-height: 20px;
margin-top: 9px;
}
}

View File

@@ -1,45 +1,53 @@
.alert {
padding-left: 30px;
padding-right: 30px;
.alert-page h3 {
flex-grow: 1;
span {
cursor: pointer;
input {
margin: -0.2em 0;
width: 100%;
min-width: 170px;
}
}
.alert-dismissable,
.alert-dismissible {
padding-right: 44px;
}
.alert-inverse {
.alert-variant(@alert-inverse-bg; @alert-inverse-border; @alert-inverse-text);
.btn-create-alert[disabled] {
display: block;
margin-top: -20px;
}
.alert-link {
color: #fff !important;
font-weight: normal !important;
text-decoration: underline;
.alert-state {
border-bottom: 1px solid @input-border;
padding-bottom: 30px;
.alert-state-indicator {
text-transform: uppercase;
font-size: 14px;
padding: 5px 8px;
}
.alert-last-triggered {
color: @headings-color;
}
}
.growl-animated {
&.alert-inverse {
box-shadow: 0 0 5px fade(@alert-inverse-bg, 50%);
}
&.alert-info {
box-shadow: 0 0 5px fade(@alert-info-bg, 50%);
}
.alert-query-selector {
min-width: 250px;
width: auto !important;
}
&.alert-success {
box-shadow: 0 0 5px fade(@alert-success-bg, 50%);
}
// allow form item labels to gracefully break line
.alert-form-item label {
white-space: initial;
padding-right: 8px;
line-height: 21px;
&.alert-warning {
box-shadow: 0 0 5px fade(@alert-warning-bg, 50%);
}
&.alert-danger {
box-shadow: 0 0 5px fade(@alert-danger-bg, 50%);
&::after {
margin-right: 0 !important;
}
}
.alert-actions {
flex-grow: 1;
display: flex;
justify-content: flex-end;
align-items: center;
margin-right: -15px;
}

View File

@@ -36,6 +36,7 @@
-----------------------------------------------------------*/
@input-height-base: 35px;
@input-color: #595959;
@input-color-placeholder: #b4b4b4;
@border-radius-base: 2px;
@border-color-base: #E8E8E8;

View File

@@ -19,11 +19,22 @@ html, body {
}
body {
padding-top: @header-height;
padding-top: 0;
background: #F6F8F9;
font-family: @redash-font;
position: relative;
app-view {
padding-bottom: 15px;
}
&.headless {
padding-top: 0;
.nav.app-header {
app-view {
padding-top: 10px;
padding-bottom: 0;
}
.app-header-wrapper {
display: none;
}
}
@@ -72,10 +83,34 @@ strong {
}
}
// Fixed width layout for specific pages
@media (min-width: 768px) {
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
.container {
width: 750px;
}
}
}
@media (min-width: 992px) {
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
.container {
width: 970px;
}
}
}
@media (min-width: 1200px) {
settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container {
.container {
width: 1170px;
}
}
}
.scrollbox {
overflow: auto;
position: relative;
}
.clickable {
@@ -95,3 +130,154 @@ strong {
resize: both !important;
transition: height 0s, width 0s !important;
}
// Ace Editor
.ace_editor {
border: 1px solid fade(@redash-gray, 15%) !important;
}
.ace-tm {
.ace_gutter {
background: #fff !important;
}
.ace_gutter-active-line {
background-color: fade(@redash-gray, 20%) !important;
}
.ace_marker-layer .ace_active-line {
background: fade(@redash-gray, 9%) !important;
}
}
.bg-ace {
background-color: fade(@redash-gray, 12%) !important;
}
// resizeable
.rg-top span, .rg-bottom span {
height: 3px;
border-color: #b1c1ce; // TODO: variable
}
.rg-bottom {
bottom: 15px;
span {
margin: 1.5px 0 0 -10px;
}
}
// Plotly
text.slicetext {
text-shadow: 1px 1px 5px #333;
}
// markdown
.markdown strong {
font-weight: bold;
}
.markdown img {
max-width: 100%;
}
.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus {
background-color: fade(@redash-gray, 15%);
color: #111;
}
.profile__image--sidebar {
border-radius: 100%;
margin-right: 3px;
margin-top: -2px;
}
.profile__image--settings {
border-radius: 100%;
}
.profile__image_thumb {
border-radius: 100%;
margin-right: 3px;
margin-top: -2px;
width: 20px;
height: 20px;
}
// Error state
.error-state {
display: flex;
flex-direction: column;
justify-content: flex-start;
text-align: center;
margin-top: 25vh;
padding: 35px;
font-size: 14px;
line-height: 21px;
.error-state__icon {
.zmdi {
font-size: 64px;
color: @redash-gray;
}
}
@media (max-width: 767px) {
margin-top: 10vh;
}
}
.warning-icon-danger {
color: @red !important;
}
// page
.page-header--new .btn-favourite, .page-header--new .btn-archive {
font-size: 19px;
}
.page-title {
display: flex;
align-items: center;
h3 {
margin-right: 5px !important;
}
.label {
margin-top: 3px;
display: inline-block;
}
favorites-control {
margin-right: 5px;
}
@media (max-width: 767px) {
display: block;
favorites-control {
float: left;
}
h3 {
width: 100%;
margin-bottom: 5px !important;
display: block !important;
}
}
}
.page-header-wrapper, .page-header--new {
h3 {
margin: 0.2em 0;
line-height: 1.3;
font-weight: 500;
}
}
.select-option-divider {
margin: 10px 0 !important;
}

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

@@ -76,6 +76,8 @@
.font-size(20, 8px, 8);
.f-inherit { font-size: inherit !important; }
/* --------------------------------------------------------
Font Weight
@@ -153,4 +155,10 @@
/* --------------------------------------------------------
Border Radius
-----------------------------------------------------------*/
.brd-2 { border-radius: 2px; }
.brd-2 { border-radius: 2px; }
/* --------------------------------------------------------
Alignment
-----------------------------------------------------------*/
.va-top { vertical-align: top; }

View File

@@ -1,14 +1,37 @@
.label {
border-radius: 1px;
padding: 4px 5px 3px;
}
h1, h2, h3, h4, h5, h6 {
.label {
border-radius: 2px;
}
border-radius: 2px;
padding: 3px 6px 4px;
font-weight: 500;
font-size: 11px;
}
.badge {
border-radius: 1px;
}
.label-default {
background: fade(@redash-gray, 85%);
}
.label-tag-unpublished {
background: fade(@redash-gray, 85%);
}
.label-tag-archived {
.label-warning();
}
.label-tag {
background: fade(@redash-gray, 10%);
color: fade(@redash-gray, 75%);
}
.label-tag-unpublished,
.label-tag-archived,
.label-tag {
margin-right: 3px;
display: inline;
margin-top: 2px;
max-width: 24ch;
.text-overflow();
}

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

@@ -1,32 +0,0 @@
a.navbar-brand {
padding: 5px 5px 0px 0px;
}
.navbar .fa {
font-size: 18px;
}
.navbar .collapse.in {
background: #222;
}
a.navbar-brand img {
height: 40px;
}
.avatar {
margin-top: 5px;
margin-bottom: 5px;
}
.avatar img {
width: 40px;
height: 40px;
}
#logout {
color: white;
position: relative;
left: -9px;
bottom: -11px;
}

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

@@ -1,26 +1,26 @@
.table {
margin-bottom: 0;
th.sortable-column {
cursor: pointer;
cursor: pointer;
}
&:not(.table-striped) > thead > tr > th {
background-color: #FAFAFA;
}
[class*="bg-"] {
& > tr > th {
color: #fff;
border-bottom: 0;
background: transparent !important;
}
& + tbody > tr:first-child > td {
border-top: 0;
}
}
& > thead > tr > th {
vertical-align: middle;
font-weight: 500;
@@ -29,24 +29,24 @@
text-transform: uppercase;
padding: 15px 10px;
}
& > thead > tr,
& > tbody > tr,
& > tfoot > tr {
& > th, & > td {
&:first-child {
padding-left: 30px;
}
&:last-child {
padding-right: 30px;
}
}
}
tbody > tr:last-child > td {
padding-bottom: 20px;
}
@@ -54,21 +54,21 @@
.table-bordered {
border: 0;
& > tbody > tr {
& > td, & > th {
border-bottom: 0;
border-left: 0;
&:last-child {
border-right: 0;
}
}
}
& > thead > tr > th {
border-left: 0;
&:last-child {
border-right: 0;
}
@@ -86,14 +86,64 @@
}
.tile .table {
& > thead:not([class*="bg-"]) > tr > th {
border-top: 1px solid @table-border-color;
}
}
.table-hover > tbody > tr:hover {
background-color: #f4f4f4;
background-color: #f4f4f4;
}
.table-data {
tbody > tr > td {
padding-top: 5px !important;
}
.btn-favourite, .btn-archive {
font-size: 15px;
}
}
.table-main-title {
font-weight: 500;
line-height: 1.7 !important;
}
.btn-favourite {
color: #d4d4d4;
transition: all .25s ease-in-out;
&:hover, &:focus {
color: @yellow-darker;
cursor: pointer;
}
.fa-star {
color: @yellow-darker;
}
}
.btn-archive {
color: #d4d4d4;
transition: all .25s ease-in-out;
&:hover, &:focus {
color: @gray-light;
}
.fa-archive {
color: @gray-light;
}
}
.table > thead > tr > th {
text-transform: none;
}
.table-data .label-tag {
display: inline-block;
max-width: 135px;
}

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,4 +1,4 @@
visualization-renderer {
.visualization-renderer {
display: block;
.pagination,

View File

@@ -1,3 +1,4 @@
.pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
.pivot-table-visualization-container > table,
.visualization-renderer > .visualization-renderer-wrapper {
overflow: auto;
}

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';
@@ -45,7 +44,6 @@
@import 'inc/profile';
@import 'inc/404';
@import 'inc/ie-warning';
@import 'inc/navbar';
@import 'inc/edit-in-place';
@import 'inc/growl';
@import 'inc/flex';
@@ -54,12 +52,8 @@
@import 'inc/schema-browser';
@import 'inc/toast';
@import 'inc/visualizations/box';
@import 'inc/visualizations/counter-render';
@import 'inc/visualizations/sankey';
@import 'inc/visualizations/pivot-table';
@import 'inc/visualizations/map';
@import 'inc/visualizations/chart';
@import 'inc/visualizations/sunburst';
@import 'inc/visualizations/cohort';
@import 'inc/visualizations/misc';
@@ -71,11 +65,11 @@
@import 'inc/vendor-overrides/ui-select';
/** REDASH STYLING **/
@import 'redash/redash-newstyle';
@import 'redash/redash-table';
@import 'redash/query';
@import 'redash/tags-control';
@import 'redash/css-logo';
@import 'redash/loading-indicator';

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

@@ -5,6 +5,7 @@ body.fixed-layout {
app-view {
display: flex;
flex-direction: column;
padding-bottom: 0;
width: 100vw;
height: 100vh;
@@ -92,7 +93,7 @@ edit-in-place p.editable:hover {
}
.filter-container {
margin-bottom: 10px;
margin-bottom: 5px;
}
.ace_editor.ace_autocomplete .ace_completion-highlight {
@@ -208,18 +209,18 @@ edit-in-place p.editable:hover {
}
}
.visualization-renderer {
.pagination,
.ant-pagination {
margin-top: 10px;
}
}
.embed__vis {
display: flex;
flex-flow: column;
}
.embed-heading {
h3 {
line-height: 1.75;
margin: 0;
}
}
.widget-wrapper {
.body-container {
.filters-wrapper {
@@ -343,7 +344,8 @@ a.label-tag {
border-bottom: 1px solid #efefef;
}
.pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
.pivot-table-visualization-container > table,
.visualization-renderer > .visualization-renderer-wrapper {
overflow: visible;
}
@@ -676,8 +678,17 @@ nav .rg-bottom {
.filter-container {
padding-right: 0;
}
}
.btn-edit-visualisation {
// Responsive fixes
@media (max-width: 767px) {
.query-page-wrapper {
h3 {
font-size: 18px;
}
favorites-control {
margin-top: -3px;
}
}
}

File diff suppressed because it is too large Load Diff

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

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

@@ -11,7 +11,7 @@ import { PreviewCard } from '@/components/PreviewCard';
import EmptyState from '@/components/items-list/components/EmptyState';
import DynamicForm from '@/components/dynamic-form/DynamicForm';
import helper from '@/components/dynamic-form/dynamicFormHelper';
import { HelpTrigger, TYPES as HELP_TRIGGER_TYPES } from '@/components/HelpTrigger';
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from '@/components/HelpTrigger';
const { Step } = Steps;
const { Search } = Input;
@@ -100,7 +100,7 @@ class CreateSourceDialog extends React.Component {
const fields = helper.getFields(selectedType);
const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`;
return (
<div className="p-5">
<div>
<div className="d-flex justify-content-center align-items-center">
<img
className="p-5"

View File

@@ -1,23 +1,22 @@
import { includes, words, capitalize, clone, isNull } from 'lodash';
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Checkbox from 'antd/lib/checkbox';
import Modal from 'antd/lib/modal';
import Form from 'antd/lib/form';
import Button from 'antd/lib/button';
import Select from 'antd/lib/select';
import Input from 'antd/lib/input';
import Divider from 'antd/lib/divider';
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
import { QuerySelector } from '@/components/QuerySelector';
import { Query } from '@/services/query';
import { includes, words, capitalize, clone, isNull } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal";
import Form from "antd/lib/form";
import Button from "antd/lib/button";
import Select from "antd/lib/select";
import Input from "antd/lib/input";
import Divider from "antd/lib/divider";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { QuerySelector } from "@/components/QuerySelector";
import { Query } from "@/services/query";
const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
function getDefaultTitle(text) {
return capitalize(words(text).join(' ')); // humanize
return capitalize(words(text).join(" ")); // humanize
}
function isTypeDateRange(type) {
@@ -26,28 +25,28 @@ function isTypeDateRange(type) {
function joinExampleList(multiValuesOptions) {
const { prefix, suffix } = multiValuesOptions;
return ['value1', 'value2', 'value3']
.map(value => `${prefix}${value}${suffix}`)
.join(',');
return ["value1", "value2", "value3"]
.map((value) => `${prefix}${value}${suffix}`)
.join(",");
}
function NameInput({ name, type, onChange, existingNames, setValidation }) {
let helpText = '';
let validateStatus = '';
let helpText = "";
let validateStatus = "";
if (!name) {
helpText = 'Choose a keyword for this parameter';
helpText = "Choose a keyword for this parameter";
setValidation(false);
} else if (includes(existingNames, name)) {
helpText = 'Parameter with this name already exists';
helpText = "Parameter with this name already exists";
setValidation(false);
validateStatus = 'error';
validateStatus = "error";
} else {
if (isTypeDateRange(type)) {
helpText = (
<React.Fragment>
Appears in query as {' '}
<code style={{ display: 'inline-block', color: 'inherit' }}>
Appears in query as{" "}
<code style={{ display: "inline-block", color: "inherit" }}>
{`{{${name}.start}} {{${name}.end}}`}
</code>
</React.Fragment>
@@ -64,7 +63,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) {
validateStatus={validateStatus}
{...formItemProps}
>
<Input onChange={e => onChange(e.target.value)} autoFocus />
<Input onChange={(e) => onChange(e.target.value)} autoFocus />
</Form.Item>
);
}
@@ -101,12 +100,12 @@ function EditParameterSettingsDialog(props) {
}
// title
if (param.title === '') {
if (param.title === "") {
return false;
}
// query
if (param.type === 'query' && !param.queryId) {
if (param.type === "query" && !param.queryId) {
return false;
}
@@ -129,21 +128,29 @@ function EditParameterSettingsDialog(props) {
return (
<Modal
{...props.dialog.props}
title={isNew ? 'Add Parameter' : param.name}
title={isNew ? "Add Parameter" : param.name}
width={600}
footer={[(
<Button key="cancel" onClick={props.dialog.dismiss}>Cancel</Button>
), (
<Button key="submit" htmlType="submit" disabled={!isFulfilled()} type="primary" form="paramForm" data-test="SaveParameterSettings">
{isNew ? 'Add Parameter' : 'OK'}
</Button>
)]}
footer={[
<Button key="cancel" onClick={props.dialog.dismiss}>
Cancel
</Button>,
<Button
key="submit"
htmlType="submit"
disabled={!isFulfilled()}
type="primary"
form="paramForm"
data-test="SaveParameterSettings"
>
{isNew ? "Add Parameter" : "OK"}
</Button>,
]}
>
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
{isNew && (
<NameInput
name={param.name}
onChange={name => setParam({ ...param, name })}
onChange={(name) => setParam({ ...param, name })}
setValidation={setIsNameValid}
existingNames={props.existingParams}
type={param.type}
@@ -151,90 +158,144 @@ function EditParameterSettingsDialog(props) {
)}
<Form.Item label="Title" {...formItemProps}>
<Input
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
onChange={e => setParam({ ...param, title: e.target.value })}
value={
isNull(param.title) ? getDefaultTitle(param.name) : param.title
}
onChange={(e) => setParam({ ...param, title: e.target.value })}
data-test="ParameterTitleInput"
/>
</Form.Item>
<Form.Item label="Type" {...formItemProps}>
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect">
<Option value="text" data-test="TextParameterTypeOption">Text</Option>
<Option value="number" data-test="NumberParameterTypeOption">Number</Option>
<Select
value={param.type}
onChange={(type) => setParam({ ...param, type })}
data-test="ParameterTypeSelect"
>
<Option value="text" data-test="TextParameterTypeOption">
Text
</Option>
<Option value="number" data-test="NumberParameterTypeOption">
Number
</Option>
<Option value="enum">Dropdown List</Option>
<Option value="query">Query Based Dropdown List</Option>
<Option disabled key="dv1">
<Divider className="select-option-divider" />
</Option>
<Option value="date" data-test="DateParameterTypeOption">Date</Option>
<Option value="datetime-local" data-test="DateTimeParameterTypeOption">Date and Time</Option>
<Option value="datetime-with-seconds">Date and Time (with seconds)</Option>
<Option value="date" data-test="DateParameterTypeOption">
Date
</Option>
<Option
value="datetime-local"
data-test="DateTimeParameterTypeOption"
>
Date and Time
</Option>
<Option value="datetime-with-seconds">
Date and Time (with seconds)
</Option>
<Option disabled key="dv2">
<Divider className="select-option-divider" />
</Option>
<Option value="date-range" data-test="DateRangeParameterTypeOption">Date Range</Option>
<Option value="date-range" data-test="DateRangeParameterTypeOption">
Date Range
</Option>
<Option value="datetime-range">Date and Time Range</Option>
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
<Option value="datetime-range-with-seconds">
Date and Time Range (with seconds)
</Option>
</Select>
</Form.Item>
{param.type === 'enum' && (
<Form.Item label="Values" help="Dropdown list values (newline delimeted)" {...formItemProps}>
{param.type === "enum" && (
<Form.Item
label="Values"
help="Dropdown list values (newline delimited)"
{...formItemProps}
>
<Input.TextArea
data-test="EnumTextArea"
rows={3}
value={param.enumOptions}
onChange={e => setParam({ ...param, enumOptions: e.target.value })}
onChange={(e) =>
setParam({ ...param, enumOptions: e.target.value })
}
/>
</Form.Item>
)}
{param.type === 'query' && (
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
{param.type === "query" && (
<Form.Item
label="Query"
help="Select query to load dropdown values from"
{...formItemProps}
>
<QuerySelector
selectedQuery={initialQuery}
onChange={q => setParam({ ...param, queryId: q && q.id })}
onChange={(q) => setParam({ ...param, queryId: q && q.id })}
type="select"
/>
</Form.Item>
)}
{(param.type === 'enum' || param.type === 'query') && (
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
{(param.type === "enum" || param.type === "query") && (
<Form.Item
className="m-b-0"
label=" "
colon={false}
{...formItemProps}
>
<Checkbox
defaultChecked={!!param.multiValuesOptions}
onChange={e => setParam({ ...param,
multiValuesOptions: e.target.checked ? {
prefix: '',
suffix: '',
separator: ',',
} : null })}
onChange={(e) =>
setParam({
...param,
multiValuesOptions: e.target.checked
? {
prefix: "",
suffix: "",
separator: ",",
}
: null,
})
}
data-test="AllowMultipleValuesCheckbox"
>
Allow multiple values
Allow multiple values
</Checkbox>
</Form.Item>
)}
{(param.type === 'enum' || param.type === 'query') && param.multiValuesOptions && (
<Form.Item
label="Quotation"
help={(
<React.Fragment>
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
</React.Fragment>
)}
{...formItemProps}
>
<Select
value={param.multiValuesOptions.prefix}
onChange={quoteOption => setParam({ ...param,
multiValuesOptions: {
...param.multiValuesOptions,
prefix: quoteOption,
suffix: quoteOption,
} })}
data-test="QuotationSelect"
{(param.type === "enum" || param.type === "query") &&
param.multiValuesOptions && (
<Form.Item
label="Quotation"
help={
<React.Fragment>
Placed in query as:{" "}
<code>{joinExampleList(param.multiValuesOptions)}</code>
</React.Fragment>
}
{...formItemProps}
>
<Option value="">None (default)</Option>
<Option value="'">Single Quotation Mark</Option>
<Option value={'"'} data-test="DoubleQuotationMarkOption">Double Quotation Mark</Option>
</Select>
</Form.Item>
)}
<Select
value={param.multiValuesOptions.prefix}
onChange={(quoteOption) =>
setParam({
...param,
multiValuesOptions: {
...param.multiValuesOptions,
prefix: quoteOption,
suffix: quoteOption,
},
})
}
data-test="QuotationSelect"
>
<Option value="">None (default)</Option>
<Option value="'">Single Quotation Mark</Option>
<Option value={'"'} data-test="DoubleQuotationMarkOption">
Double Quotation Mark
</Option>
</Select>
</Form.Item>
)}
</Form>
</Modal>
);

View File

@@ -20,7 +20,7 @@ export default function QueryResultsLink(props) {
}
return (
<a target="_self" disabled={props.disabled} href={href}>
<a target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
{props.children}
</a>
);

View File

@@ -1,22 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular';
import { currentUser, clientConfig } from '@/services/auth';
import cx from 'classnames';
import { clientConfig, currentUser } from '@/services/auth';
import Tooltip from 'antd/lib/tooltip';
import Alert from 'antd/lib/alert';
import HelpTrigger from '@/components/HelpTrigger';
export function EmailSettingsWarning({ featureName }) {
return (clientConfig.mailSettingsMissing && currentUser.isAdmin) ? (
<p className="alert alert-danger">
{`It looks like your mail server isn't configured. Make sure to configure it for the ${featureName} to work.`}
</p>
) : null;
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }) {
if (!clientConfig.mailSettingsMissing) {
return null;
}
if (adminOnly && !currentUser.isAdmin) {
return null;
}
const message = (
<span>
Your mail server isn&apos;t configured correctly, and is needed for {featureName} to work.{' '}
<HelpTrigger type="MAIL_CONFIG" className="f-inherit" />
</span>
);
if (mode === 'icon') {
return (
<Tooltip title={message}>
<i className={cx('fa fa-exclamation-triangle', className)} />
</Tooltip>
);
}
return (
<Alert message={message} type="error" className={className} />
);
}
EmailSettingsWarning.propTypes = {
featureName: PropTypes.string.isRequired,
className: PropTypes.string,
mode: PropTypes.oneOf(['alert', 'icon']),
adminOnly: PropTypes.bool,
};
export default function init(ngModule) {
ngModule.component('emailSettingsWarning', react2angular(EmailSettingsWarning));
}
init.init = true;
EmailSettingsWarning.defaultProps = {
className: null,
mode: 'alert',
adminOnly: false,
};

View File

@@ -1,10 +1,10 @@
import { isArray, indexOf, get, map, includes, every, some, toNumber, toLower } from 'lodash';
import { isArray, indexOf, get, map, includes, every, some, toNumber } from 'lodash';
import moment from 'moment';
import React from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular';
import Select from 'antd/lib/select';
import { formatDateTime, formatDate } from '@/filters/datetime';
import { formatColumnValue } from '@/filters';
const ALL_VALUES = '###Redash::Filters::SelectAll###';
const NONE_VALUES = '###Redash::Filters::Clear###';
@@ -71,21 +71,6 @@ export function filterData(rows, filters = []) {
return result;
}
function formatValue(value, columnType) {
if (moment.isMoment(value)) {
if (columnType === 'date') {
return formatDate(value);
}
return formatDateTime(value);
}
if (typeof value === 'boolean') {
return value.toString();
}
return value;
}
export function Filters({ filters, onChange }) {
if (filters.length === 0) {
return null;
@@ -99,7 +84,7 @@ export function Filters({ filters, onChange }) {
<div className="row">
{map(filters, (filter) => {
const options = map(filter.values, (value, index) => (
<Select.Option key={index}>{formatValue(value, get(filter, 'column.type'))}</Select.Option>
<Select.Option key={index}>{formatColumnValue(value, get(filter, 'column.type'))}</Select.Option>
));
return (
@@ -115,10 +100,10 @@ export function Filters({ filters, onChange }) {
mode={filter.multiple ? 'multiple' : 'default'}
value={isArray(filter.current) ?
map(filter.current,
value => ({ key: `${indexOf(filter.values, value)}`, label: formatValue(value) })) :
({ key: `${indexOf(filter.values, filter.current)}`, label: formatValue(filter.current) })}
value => ({ key: `${indexOf(filter.values, value)}`, label: formatColumnValue(value) })) :
({ key: `${indexOf(filter.values, filter.current)}`, label: formatColumnValue(filter.current) })}
allowClear={filter.multiple}
filterOption={(searchText, option) => includes(toLower(option.props.children), toLower(searchText))}
optionFilterProp="children"
showSearch
onChange={values => onChange(filter, values)}
>

View File

@@ -1,4 +1,3 @@
import { react2angular } from 'react2angular';
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
@@ -32,6 +31,10 @@ export const TYPES = {
'/user-guide/users/authentication-options',
'Guide: Authentication Options',
],
USAGE_DATA_SHARING: [
'/open-source/admin-guide/usage-data',
'Help: Anonymous Usage Data Sharing',
],
DS_ATHENA: [
'/data-sources/amazon-athena-setup',
'Guide: Help Setting up Amazon Athena',
@@ -64,9 +67,25 @@ export const TYPES = {
'/user-guide/querying/query-results-data-source',
'Guide: Help Setting up Query Results',
],
ALERT_SETUP: [
'/user-guide/alerts/setting-up-an-alert',
'Guide: Setting Up a New Alert',
],
MAIL_CONFIG: [
'/open-source/setup/#Mail-Configuration',
'Guide: Mail Configuration',
],
ALERT_NOTIF_TEMPLATE_GUIDE: [
'/user-guide/alerts/custom-alert-notifications',
'Guide: Custom Alerts Notifications',
],
FAVORITES: [
'/user-guide/querying/favorites-tagging/#Favorites',
'Guide: Favorites',
],
};
export class HelpTrigger extends React.Component {
export default class HelpTrigger extends React.Component {
static propTypes = {
type: PropTypes.oneOf(Object.keys(TYPES)).isRequired,
className: PropTypes.string,
@@ -220,9 +239,3 @@ export class HelpTrigger extends React.Component {
);
}
}
export default function init(ngModule) {
ngModule.component('helpTrigger', react2angular(HelpTrigger));
}
init.init = true;

View File

@@ -1,14 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular';
import Button from 'antd/lib/button';
import Badge from 'antd/lib/badge';
import Tooltip from 'antd/lib/tooltip';
import { KeyboardShortcuts } from '@/services/keyboard-shortcuts';
function ParameterApplyButton({ paramCount, onClick, isApplying }) {
// show spinner when applying (also when count is empty so the fade out is consistent)
const icon = isApplying || !paramCount ? 'spinner fa-pulse' : 'check';
function ParameterApplyButton({ paramCount, onClick }) {
// show spinner when count is empty so the fade out is consistent
const icon = !paramCount ? 'spinner fa-pulse' : 'check';
return (
<div className="parameter-apply-button" data-show={!!paramCount} data-test="ParameterApplyButton">
@@ -28,11 +27,6 @@ function ParameterApplyButton({ paramCount, onClick, isApplying }) {
ParameterApplyButton.propTypes = {
onClick: PropTypes.func.isRequired,
paramCount: PropTypes.number.isRequired,
isApplying: PropTypes.bool.isRequired,
};
export default function init(ngModule) {
ngModule.component('parameterApplyButton', react2angular(ParameterApplyButton));
}
init.init = true;
export default ParameterApplyButton;

View File

@@ -1,6 +1,7 @@
/* eslint-disable react/no-multi-comp */
import { isString, extend, each, map, includes, findIndex, find, fromPairs, clone, isEmpty } from 'lodash';
import { isString, extend, each, has, map, includes, findIndex, find,
fromPairs, clone, isEmpty } from 'lodash';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -14,10 +15,10 @@ import Input from 'antd/lib/input';
import Radio from 'antd/lib/radio';
import Form from 'antd/lib/form';
import Tooltip from 'antd/lib/tooltip';
import { ParameterValueInput } from '@/components/ParameterValueInput';
import ParameterValueInput from '@/components/ParameterValueInput';
import { ParameterMappingType } from '@/services/widget';
import { Parameter } from '@/services/query';
import { HelpTrigger } from '@/components/HelpTrigger';
import { Parameter } from '@/services/parameters';
import HelpTrigger from '@/components/HelpTrigger';
import './ParameterMappingInput.less';
@@ -158,6 +159,13 @@ export class ParameterMappingInput extends React.Component {
newMapping.param = newMapping.param.clone();
newMapping.param.setValue(newMapping.value);
}
if (has(update, 'type')) {
if (update.type === MappingType.StaticValue) {
newMapping.value = newMapping.param.value;
} else {
newMapping.value = null;
}
}
onChange(newMapping);
};
@@ -168,7 +176,7 @@ export class ParameterMappingInput extends React.Component {
value={this.props.mapping.type}
onChange={e => this.updateSourceType(e.target.value)}
>
<Radio className="radio" value={MappingType.DashboardAddNew}>
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
New dashboard parameter
</Radio>
<Radio
@@ -183,10 +191,10 @@ export class ParameterMappingInput extends React.Component {
</Tooltip>
) : null }
</Radio>
<Radio className="radio" value={MappingType.WidgetLevel}>
<Radio className="radio" value={MappingType.WidgetLevel} data-test="WidgetParameterOption">
Widget parameter
</Radio>
<Radio className="radio" value={MappingType.StaticValue}>
<Radio className="radio" value={MappingType.StaticValue} data-test="StaticValueOption">
Static value
</Radio>
</Radio.Group>
@@ -335,7 +343,7 @@ class MappingEditor extends React.Component {
const { mapping, inputError } = this.state;
return (
<div className="parameter-mapping-editor">
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
<header>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</header>
@@ -354,15 +362,16 @@ class MappingEditor extends React.Component {
}
render() {
const { visible, mapping } = this.state;
return (
<Popover
placement="left"
trigger="click"
content={this.renderContent()}
visible={this.state.visible}
visible={visible}
onVisibleChange={this.onVisibleChange}
>
<Button size="small" type="dashed">
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
<Icon type="edit" />
</Button>
</Popover>
@@ -536,11 +545,11 @@ export class ParameterMappingListInput extends React.Component {
param = param.clone().setValue(mapping.value);
}
let value = Parameter.getValue(param);
let value = Parameter.getExecutionValue(param);
// in case of dynamic value display the name instead of value
if (param.hasDynamicValue) {
value = param.dynamicValue.name;
value = param.normalizedValue.name;
}
return this.getStringValue(value);

View File

@@ -1,51 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular';
import Select from 'antd/lib/select';
import Input from 'antd/lib/input';
import InputNumber from 'antd/lib/input-number';
import DateParameter from '@/components/dynamic-parameters/DateParameter';
import DateRangeParameter from '@/components/dynamic-parameters/DateRangeParameter';
import { toString } from 'lodash';
import { QueryBasedParameterInput } from './QueryBasedParameterInput';
import React from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number";
import DateParameter from "@/components/dynamic-parameters/DateParameter";
import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
import { isEqual, trim } from "lodash";
import { QueryBasedParameterInput } from "./QueryBasedParameterInput";
import './ParameterValueInput.less';
import "./ParameterValueInput.less";
const { Option } = Select;
const multipleValuesProps = {
maxTagCount: 3,
maxTagTextLength: 10,
maxTagPlaceholder: num => `+${num.length} more`,
maxTagPlaceholder: (num) => `+${num.length} more`,
};
export class ParameterValueInput extends React.Component {
class ParameterValueInput extends React.Component {
static propTypes = {
type: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
enumOptions: PropTypes.string,
queryId: PropTypes.number,
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
allowMultipleValues: PropTypes.bool,
onSelect: PropTypes.func,
className: PropTypes.string,
};
static defaultProps = {
type: 'text',
type: "text",
value: null,
enumOptions: '',
enumOptions: "",
queryId: null,
parameter: null,
allowMultipleValues: false,
onSelect: () => {},
className: '',
className: "",
};
constructor(props) {
super(props);
this.state = {
value: props.parameter.hasPendingValue ? props.parameter.pendingValue : props.value,
value: props.parameter.hasPendingValue
? props.parameter.pendingValue
: props.value,
isDirty: props.parameter.hasPendingValue,
};
}
@@ -59,13 +58,13 @@ export class ParameterValueInput extends React.Component {
isDirty: parameter.hasPendingValue,
});
}
}
};
onSelect = (value) => {
const isDirty = toString(value) !== toString(this.props.value);
const isDirty = !isEqual(trim(value), trim(this.props.value));
this.setState({ value, isDirty });
this.props.onSelect(value, isDirty);
}
};
renderDateParameter() {
const { type, parameter } = this.props;
@@ -96,37 +95,43 @@ export class ParameterValueInput extends React.Component {
}
renderEnumInput() {
const { enumOptions, allowMultipleValues } = this.props;
const { enumOptions, parameter } = this.props;
const { value } = this.state;
const enumOptionsArray = enumOptions.split('\n').filter(v => v !== '');
const enumOptionsArray = enumOptions.split("\n").filter((v) => v !== "");
// Antd Select doesn't handle null in multiple mode
const normalize = (val) =>
parameter.multiValuesOptions && val === null ? [] : val;
return (
<Select
className={this.props.className}
mode={allowMultipleValues ? 'multiple' : 'default'}
mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
disabled={enumOptionsArray.length === 0}
value={value}
value={normalize(value)}
onChange={this.onSelect}
dropdownMatchSelectWidth={false}
dropdownClassName="ant-dropdown-in-bootstrap-modal"
showSearch
showArrow
style={{ minWidth: 60 }}
notFoundContent={null}
{...multipleValuesProps}
>
{enumOptionsArray.map(option => (<Option key={option} value={option}>{ option }</Option>))}
{enumOptionsArray.map((option) => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
);
}
renderQueryBasedInput() {
const { queryId, parameter, allowMultipleValues } = this.props;
const { queryId, parameter } = this.props;
const { value } = this.state;
return (
<QueryBasedParameterInput
className={this.props.className}
mode={allowMultipleValues ? 'multiple' : 'default'}
mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children"
parameter={parameter}
value={value}
@@ -142,13 +147,11 @@ export class ParameterValueInput extends React.Component {
const { className } = this.props;
const { value } = this.state;
const normalize = val => !isNaN(val) && val || 0;
return (
<InputNumber
className={className}
value={normalize(value)}
onChange={val => this.onSelect(normalize(val))}
value={value}
onChange={(val) => this.onSelect(val)}
/>
);
}
@@ -162,7 +165,7 @@ export class ParameterValueInput extends React.Component {
className={className}
value={value}
data-test="TextParamInput"
onChange={e => this.onSelect(e.target.value)}
onChange={(e) => this.onSelect(e.target.value)}
/>
);
}
@@ -170,16 +173,22 @@ export class ParameterValueInput extends React.Component {
renderInput() {
const { type } = this.props;
switch (type) {
case 'datetime-with-seconds':
case 'datetime-local':
case 'date': return this.renderDateParameter();
case 'datetime-range-with-seconds':
case 'datetime-range':
case 'date-range': return this.renderDateRangeParameter();
case 'enum': return this.renderEnumInput();
case 'query': return this.renderQueryBasedInput();
case 'number': return this.renderNumberInput();
default: return this.renderTextInput();
case "datetime-with-seconds":
case "datetime-local":
case "date":
return this.renderDateParameter();
case "datetime-range-with-seconds":
case "datetime-range":
case "date-range":
return this.renderDateRangeParameter();
case "enum":
return this.renderEnumInput();
case "query":
return this.renderQueryBasedInput();
case "number":
return this.renderNumberInput();
default:
return this.renderTextInput();
}
}
@@ -187,41 +196,15 @@ export class ParameterValueInput extends React.Component {
const { isDirty } = this.state;
return (
<div className="parameter-input" data-dirty={isDirty || null}>
<div
className="parameter-input"
data-dirty={isDirty || null}
data-test="ParameterValueInput"
>
{this.renderInput()}
</div>
);
}
}
export default function init(ngModule) {
ngModule.component('parameterValueInput', {
template: `
<parameter-value-input-impl
type="$ctrl.param.type"
value="$ctrl.param.normalizedValue"
parameter="$ctrl.param"
enum-options="$ctrl.param.enumOptions"
query-id="$ctrl.param.queryId"
allow-multiple-values="!!$ctrl.param.multiValuesOptions"
on-select="$ctrl.setValue"
></parameter-value-input-impl>
`,
bindings: {
param: '<',
},
controller($scope) {
this.setValue = (value, isDirty) => {
if (isDirty) {
this.param.setPendingValue(value);
} else {
this.param.clearPendingValue();
}
$scope.$apply();
};
},
});
ngModule.component('parameterValueInputImpl', react2angular(ParameterValueInput));
}
init.init = true;
export default ParameterValueInput;

View File

@@ -5,9 +5,15 @@
.parameter-input {
display: inline-block;
position: relative;
width: 100%;
.@{ant-prefix}-input[type="text"] {
width: 195px;
.@{ant-prefix}-input,
.@{ant-prefix}-input-number {
min-width: 100% !important;
}
.@{ant-prefix}-select {
width: 100%;
}
&[data-dirty] {
@@ -18,65 +24,3 @@
}
}
}
.parameter-container {
position: relative;
.parameter-apply-button {
display: none; // default for mobile
// "floating" on desktop
@media (min-width: 768px) {
position: absolute;
bottom: -42px;
left: -15px;
border-radius: 2px;
z-index: 1;
transition: opacity 150ms ease-out;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
background-color: #ffffff;
padding: 4px;
padding-left: 16px;
opacity: 0;
display: block;
pointer-events: none; // so tooltip doesn't remain after button hides
}
&[data-show="true"] {
opacity: 1;
display: block;
pointer-events: auto;
}
button {
padding: 0 8px 0 6px;
color: #2096f3;
border-color: #50acf6;
// smaller on desktop
@media (min-width: 768px) {
font-size: 12px;
height: 27px;
}
&:hover, &:focus, &:active {
background-color: #eef7fe;
}
i {
margin-right: 3px;
}
}
.ant-badge-count {
min-width: 15px;
height: 15px;
padding: 0 5px;
font-size: 10px;
line-height: 15px;
background: #f77b74;
border-radius: 7px;
box-shadow: 0px 0px 0 1px white, -1px 1px 0 1px #5d6f7d85;
}
}
}

View File

@@ -0,0 +1,261 @@
import React from "react";
import PropTypes from "prop-types";
import { size, filter, forEach, extend, get, includes } from "lodash";
import { react2angular } from "react2angular";
import {
SortableContainer,
SortableElement,
DragHandle,
} from "@/components/sortable";
import { $location } from "@/services/ng";
import { Parameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput";
import Form from "antd/lib/form";
import Tooltip from "antd/lib/tooltip";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/filters";
import "./Parameters.less";
function updateUrl(parameters) {
const params = extend({}, $location.search());
parameters.forEach((param) => {
extend(params, param.toUrlParams());
});
Object.keys(params).forEach(
(key) => params[key] == null && delete params[key]
);
$location.search(params);
}
export class Parameters extends React.Component {
static propTypes = {
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
editable: PropTypes.bool,
disableUrlUpdate: PropTypes.bool,
onValuesChange: PropTypes.func,
onPendingValuesChange: PropTypes.func,
onParametersEdit: PropTypes.func,
queryResultErrorData: PropTypes.shape({
parameters: PropTypes.objectOf(PropTypes.string),
}),
unsavedParameters: PropTypes.arrayOf(PropTypes.string),
};
static defaultProps = {
parameters: [],
editable: false,
disableUrlUpdate: false,
onValuesChange: () => {},
onPendingValuesChange: () => {},
onParametersEdit: () => {},
queryResultErrorData: {},
unsavedParameters: null,
};
constructor(props) {
super(props);
const { parameters } = props;
this.state = {
parameters,
touched: {},
};
if (!props.disableUrlUpdate) {
updateUrl(parameters);
}
}
componentDidUpdate = (prevProps) => {
const { parameters, disableUrlUpdate, queryResultErrorData } = this.props;
if (prevProps.parameters !== parameters) {
this.setState({ parameters });
if (!disableUrlUpdate) {
updateUrl(parameters);
}
}
// reset touched flags on new error data
if (prevProps.queryResultErrorData !== queryResultErrorData) {
this.setState({ touched: {} });
}
};
handleKeyDown = (e) => {
// Cmd/Ctrl/Alt + Enter
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
e.stopPropagation();
this.applyChanges();
}
};
setPendingValue = (param, value, isDirty) => {
const { onPendingValuesChange } = this.props;
this.setState(({ parameters, touched }) => {
if (isDirty) {
param.setPendingValue(value);
touched = { ...touched, [param.name]: true };
} else {
param.clearPendingValue();
}
onPendingValuesChange();
return { parameters, touched };
});
};
moveParameter = ({ oldIndex, newIndex }) => {
const { onParametersEdit } = this.props;
if (oldIndex !== newIndex) {
this.setState(({ parameters }) => {
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
onParametersEdit();
return { parameters };
});
}
};
applyChanges = () => {
const { onValuesChange, disableUrlUpdate } = this.props;
this.setState(({ parameters }) => {
const parametersWithPendingValues = parameters.filter(
(p) => p.hasPendingValue
);
forEach(parameters, (p) => p.applyPendingValue());
if (!disableUrlUpdate) {
updateUrl(parameters);
}
onValuesChange(parametersWithPendingValues);
return { parameters };
});
};
showParameterSettings = (parameter, index) => {
const { onParametersEdit } = this.props;
EditParameterSettingsDialog.showModal({ parameter }).result.then(
(updated) => {
this.setState(({ parameters, touched }) => {
touched = { ...touched, [parameter.name]: true };
const updatedParameter = extend(parameter, updated);
parameters[index] = Parameter.create(
updatedParameter,
updatedParameter.parentQueryId
);
onParametersEdit();
return { parameters, touched };
});
}
);
};
getParameterFeedback = (param) => {
// error msg
const { queryResultErrorData } = this.props;
const error = get(queryResultErrorData, ["parameters", param.name], false);
if (error) {
const feedback = <Tooltip title={error}>{error}</Tooltip>;
return [feedback, "error"];
}
// unsaved
const { unsavedParameters } = this.props;
if (includes(unsavedParameters, param.name)) {
const feedback = (
<>
Unsaved{" "}
<Tooltip title='Click the "Save" button to preserve this parameter.'>
<i className="fa fa-question-circle" />
</Tooltip>
</>
);
return [feedback, "warning"];
}
return [];
};
renderParameter(param, index) {
const { editable } = this.props;
const touched = this.state.touched[param.name];
const [feedback, status] = this.getParameterFeedback(param);
return (
<div
key={param.name}
className="di-block"
data-test={`ParameterName-${param.name}`}
>
<div className="parameter-heading">
<label>{param.title || toHuman(param.name)}</label>
{editable && (
<button
className="btn btn-default btn-xs m-l-5"
onClick={() => this.showParameterSettings(param, index)}
data-test={`ParameterSettings-${param.name}`}
type="button"
>
<i className="fa fa-cog" />
</button>
)}
</div>
<Form.Item
validateStatus={touched ? "" : status}
help={feedback || null}
>
<ParameterValueInput
type={param.type}
value={param.normalizedValue}
parameter={param}
enumOptions={param.enumOptions}
queryId={param.queryId}
onSelect={(value, isDirty) =>
this.setPendingValue(param, value, isDirty)
}
/>
</Form.Item>
</div>
);
}
render() {
const { parameters } = this.state;
const { editable } = this.props;
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
return (
<SortableContainer
disabled={!editable}
axis="xy"
useDragHandle
lockToContainerEdges
helperClass="parameter-dragged"
updateBeforeSortStart={this.onBeforeSortStart}
onSortEnd={this.moveParameter}
containerProps={{
className: "parameter-container",
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
}}
>
{parameters.map((param, index) => (
<SortableElement key={param.name} index={index}>
<div className="parameter-block" data-editable={editable || null}>
{editable && (
<DragHandle data-test={`DragHandle-${param.name}`} />
)}
{this.renderParameter(param, index)}
</div>
</SortableElement>
))}
<ParameterApplyButton
onClick={this.applyChanges}
paramCount={dirtyParamCount}
/>
</SortableContainer>
);
}
}
export default function init(ngModule) {
ngModule.component("parameters", react2angular(Parameters));
}
init.init = true;

View File

@@ -0,0 +1,129 @@
@import '../assets/less/ant';
.parameter-block {
display: inline-block;
background: white;
padding: 0 12px 17px 0;
vertical-align: top;
z-index: 1;
.drag-handle {
padding: 0 5px;
margin-left: -5px;
height: 36px;
}
.parameter-container.sortable-container & {
margin: 4px 0 0 4px;
padding: 3px 6px 19px;
}
&.parameter-dragged {
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
}
.ant-form-item {
margin-bottom: 0 !important;
}
.ant-form-explain {
position: absolute;
left: 0;
right: 0;
bottom: -20px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ant-form-item-control {
line-height: normal;
}
}
.parameter-heading {
display: flex;
align-items: center;
padding-bottom: 4px;
label {
margin-bottom: 1px;
overflow: hidden;
text-overflow: ellipsis;
min-width: 100%;
max-width: 195px;
white-space: nowrap;
.parameter-block[data-editable] & {
min-width: calc(100% - 27px); // make room for settings button
max-width: 195px - 27px;
}
}
}
.parameter-container {
position: relative;
&.sortable-container {
padding: 0 4px 4px 0;
}
.parameter-apply-button {
display: none; // default for mobile
// "floating" on desktop
@media (min-width: 768px) {
position: absolute;
bottom: -36px;
left: -15px;
border-radius: 2px;
z-index: 1;
transition: opacity 150ms ease-out;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
background-color: #ffffff;
padding: 4px;
padding-left: 16px;
opacity: 0;
display: block;
pointer-events: none; // so tooltip doesn't remain after button hides
}
&[data-show="true"] {
opacity: 1;
display: block;
pointer-events: auto;
}
button {
padding: 0 8px 0 6px;
color: #2096f3;
border-color: #50acf6;
// smaller on desktop
@media (min-width: 768px) {
font-size: 12px;
height: 27px;
}
&:hover, &:focus, &:active {
background-color: #eef7fe;
}
i {
margin-right: 3px;
}
}
.ant-badge-count {
min-width: 15px;
height: 15px;
padding: 0 5px;
font-size: 10px;
line-height: 15px;
background: #f77b74;
border-radius: 7px;
box-shadow: 0 0 0 1px white, -1px 1px 0 1px #5d6f7d85;
}
}
}

View File

@@ -1,4 +1,4 @@
import { find, isFunction, isArray, isEqual, toString, map, intersection } from 'lodash';
import { find, isArray, map, intersection, isEqual } from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular';
@@ -29,6 +29,7 @@ export class QueryBasedParameterInput extends React.Component {
super(props);
this.state = {
options: [],
value: null,
loading: false,
};
}
@@ -41,6 +42,24 @@ export class QueryBasedParameterInput extends React.Component {
if (this.props.queryId !== prevProps.queryId) {
this._loadOptions(this.props.queryId);
}
if (this.props.value !== prevProps.value) {
this.setValue(this.props.value);
}
}
setValue(value) {
const { options } = this.state;
if (this.props.mode === 'multiple') {
value = isArray(value) ? value : [value];
const optionValues = map(options, option => option.value);
const validValues = intersection(value, optionValues);
this.setState({ value: validValues });
return validValues;
}
const found = find(options, option => option.value === this.props.value) !== undefined;
value = found ? value : options[0].value;
this.setState({ value });
return value;
}
async _loadOptions(queryId) {
@@ -50,20 +69,12 @@ export class QueryBasedParameterInput extends React.Component {
// stale queryId check
if (this.props.queryId === queryId) {
this.setState({ options, loading: false });
if (this.props.mode === 'multiple' && isArray(this.props.value)) {
const optionValues = map(options, option => option.value);
const validValues = intersection(this.props.value, optionValues);
if (!isEqual(this.props.value, validValues)) {
this.props.onSelect(validValues);
this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) {
this.props.onSelect(updatedValue);
}
} else {
const found = find(options, option => option.value === this.props.value) !== undefined;
if (!found && isFunction(this.props.onSelect)) {
this.props.onSelect(options[0].value);
}
}
});
}
}
}
@@ -78,10 +89,9 @@ export class QueryBasedParameterInput extends React.Component {
disabled={loading || (options.length === 0)}
loading={loading}
mode={mode}
value={isArray(value) ? value : toString(value)}
value={this.state.value}
onChange={onSelect}
dropdownMatchSelectWidth={false}
dropdownClassName="ant-dropdown-in-bootstrap-modal"
optionFilterProp="children"
showSearch
showArrow

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { VisualizationType } from '@/visualizations';
import { VisualizationName } from '@/visualizations/VisualizationName';
function QueryLink({ query, visualization, readOnly }) {
const getUrl = () => {
let hash = null;
if (visualization) {
if (visualization.type === 'TABLE') {
// link to hard-coded table tab instead of the (hidden) visualization tab
hash = 'table';
} else {
hash = visualization.id;
}
}
return query.getUrl(false, hash);
};
return (
<a href={readOnly ? null : getUrl()} className="query-link">
<VisualizationName visualization={visualization} />{' '}
<span>{query.name}</span>
</a>
);
}
QueryLink.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
visualization: VisualizationType,
readOnly: PropTypes.bool,
};
QueryLink.defaultProps = {
visualization: null,
readOnly: false,
};
export default QueryLink;

View File

@@ -146,11 +146,13 @@ export function QuerySelector(props) {
notFoundContent={null}
filterOption={false}
defaultActiveFirstOption={false}
className={props.className}
data-test="QuerySelector"
>
{searchResults && searchResults.map((q) => {
const disabled = q.is_draft;
return (
<Option value={q.id} key={q.id} disabled={disabled}>
<Option value={q.id} key={q.id} disabled={disabled} className="query-selector-result" data-test={`QueryId${q.id}`}>
{q.name}{' '}
<QueryTagsControl isDraft={q.is_draft} tags={q.tags} className={cx('inline-tags-control', { disabled })} />
</Option>
@@ -161,7 +163,7 @@ export function QuerySelector(props) {
}
return (
<React.Fragment>
<span data-test="QuerySelector">
{selectedQuery ? (
<Input value={selectedQuery.name} suffix={clearIcon} readOnly />
) : (
@@ -175,7 +177,7 @@ export function QuerySelector(props) {
<div className="scrollbox" style={{ maxHeight: '50vh', marginTop: 15 }}>
{searchResults && renderResults()}
</div>
</React.Fragment>
</span>
);
}
@@ -183,12 +185,14 @@ QuerySelector.propTypes = {
onChange: PropTypes.func.isRequired,
selectedQuery: PropTypes.object, // eslint-disable-line react/forbid-prop-types
type: PropTypes.oneOf(['select', 'default']),
className: PropTypes.string,
disabled: PropTypes.bool,
};
QuerySelector.defaultProps = {
selectedQuery: null,
type: 'default',
className: null,
disabled: false,
};

View File

@@ -1,10 +1,11 @@
import { filter, debounce, find } from 'lodash';
import { filter, debounce, find, isEmpty, size } from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Modal from 'antd/lib/modal';
import Input from 'antd/lib/input';
import List from 'antd/lib/list';
import Button from 'antd/lib/button';
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
import { BigMessage } from '@/components/BigMessage';
@@ -29,6 +30,9 @@ class SelectItemsDialog extends React.Component {
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
renderStagedItem: PropTypes.func,
save: PropTypes.func, // (selectedItems[]) => Promise<any>
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
extraFooterContent: PropTypes.node,
showCount: PropTypes.bool,
};
static defaultProps = {
@@ -37,8 +41,11 @@ class SelectItemsDialog extends React.Component {
selectedItemsTitle: 'Selected items',
itemKey: item => item.id,
renderItem: () => '',
renderStagedItem: null, // use `renderItem` by default
renderStagedItem: null, // hidden by default
save: items => items,
width: '80%',
extraFooterContent: null,
showCount: false,
};
state = {
@@ -108,7 +115,7 @@ class SelectItemsDialog extends React.Component {
renderItem(item, isStagedList) {
const { renderItem, renderStagedItem } = this.props;
const isSelected = this.isSelected(item);
const render = isStagedList ? (renderStagedItem || renderItem) : renderItem;
const render = isStagedList ? renderStagedItem : renderItem;
const { content, className, isDisabled } = render(item, { isSelected });
@@ -123,23 +130,29 @@ class SelectItemsDialog extends React.Component {
}
render() {
const { dialog, dialogTitle, inputPlaceholder, selectedItemsTitle } = this.props;
const { dialog, dialogTitle, inputPlaceholder } = this.props;
const { selectedItemsTitle, renderStagedItem, width, showCount } = this.props;
const { loading, saveInProgress, items, selected } = this.state;
const hasResults = items.length > 0;
return (
<Modal
{...dialog.props}
width="80%"
className="select-items-dialog"
width={width}
title={dialogTitle}
okText="Save"
okButtonProps={{
loading: saveInProgress,
disabled: selected.length === 0,
}}
onOk={() => this.save()}
footer={(
<div className="d-flex align-items-center">
<span className="flex-fill m-r-5" style={{ textAlign: 'left', color: 'rgba(0, 0, 0, 0.5)' }}>{this.props.extraFooterContent}</span>
<Button onClick={dialog.dismiss}>Cancel</Button>
<Button onClick={() => this.save()} loading={saveInProgress} disabled={selected.length === 0} type="primary">
Save
{showCount && !isEmpty(selected) ? ` (${size(selected)})` : null}
</Button>
</div>
)}
>
<div className="d-flex align-items-center m-b-10">
<div className="w-50 m-r-10">
<div className="flex-fill">
<Input.Search
defaultValue={this.state.searchTerm}
onChange={event => this.search(event.target.value)}
@@ -147,13 +160,15 @@ class SelectItemsDialog extends React.Component {
autoFocus
/>
</div>
<div className="w-50 m-l-10">
<h5 className="m-0">{selectedItemsTitle}</h5>
</div>
{renderStagedItem && (
<div className="w-50 m-l-20">
<h5 className="m-0">{selectedItemsTitle}</h5>
</div>
)}
</div>
<div className="d-flex align-items-stretch" style={{ minHeight: '30vh', maxHeight: '50vh' }}>
<div className="w-50 m-r-10 scrollbox">
<div className="flex-fill scrollbox">
{loading && <LoadingState className="" />}
{!loading && !hasResults && (
<BigMessage icon="fa-search" message="No items match your search." className="" />
@@ -166,15 +181,17 @@ class SelectItemsDialog extends React.Component {
/>
)}
</div>
<div className="w-50 m-l-10 scrollbox">
{(selected.length > 0) && (
<List
size="small"
dataSource={selected}
renderItem={item => this.renderItem(item, true)}
/>
)}
</div>
{renderStagedItem && (
<div className="w-50 m-l-20 scrollbox">
{(selected.length > 0) && (
<List
size="small"
dataSource={selected}
renderItem={item => this.renderItem(item, true)}
/>
)}
</div>
)}
</div>
</Modal>
);

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useEffect } from 'react';
import moment from 'moment';
import { useMemo, useEffect } from 'react';
import PropTypes from 'prop-types';
import { react2angular } from 'react2angular';
import { Moment } from '@/components/proptypes';
@@ -17,7 +17,7 @@ export function Timer({ from }) {
const diff = moment.now() - startTime;
const format = diff > 1000 * 60 * 60 ? 'HH:mm:ss' : 'mm:ss'; // no HH under an hour
return moment.utc(diff).format(format);
return (<span className="rd-timer">{moment.utc(diff).format(format)}</span>);
}
Timer.propTypes = {

View File

@@ -58,10 +58,6 @@ const queryColumns = commonColumns.concat([
{ title: 'Scheduled', dataIndex: 'scheduled' },
]);
const otherTasksColumns = commonColumns.concat([
{ title: 'Task Name', dataIndex: 'task_name' },
]);
const queuesColumns = map(
['Name', 'Active', 'Reserved', 'Waiting'],
c => ({ title: c, dataIndex: c.toLowerCase() }),
@@ -97,16 +93,3 @@ export function QueriesTable({ loading, items }) {
}
QueriesTable.propTypes = TablePropTypes;
export function OtherTasksTable({ loading, items }) {
return (
<Table
loading={loading}
columns={otherTasksColumns}
rowKey="task_id"
dataSource={items}
/>
);
}
OtherTasksTable.propTypes = TablePropTypes;

View File

@@ -18,6 +18,9 @@ export default function Layout({ activeTab, children }) {
<Tabs.TabPane key="tasks" tab={<a href="admin/queries/tasks">Celery Status</a>}>
{(activeTab === 'tasks') ? children : null}
</Tabs.TabPane>
<Tabs.TabPane key="jobs" tab={<a href="admin/queries/jobs">RQ Status</a>}>
{(activeTab === 'jobs') ? children : null}
</Tabs.TabPane>
<Tabs.TabPane key="outdated_queries" tab={<a href="admin/queries/outdated">Outdated Queries</a>}>
{(activeTab === 'outdated_queries') ? children : null}
</Tabs.TabPane>

View File

@@ -0,0 +1,80 @@
import { map } from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import Badge from 'antd/lib/badge';
import Table from 'antd/lib/table';
import { Columns } from '@/components/items-list/components/ItemsTable';
// Tables
const otherJobsColumns = [
{ title: 'Queue', dataIndex: 'queue' },
{ title: 'Job Name', dataIndex: 'name' },
Columns.timeAgo({ title: 'Start Time', dataIndex: 'started_at' }),
Columns.timeAgo({ title: 'Enqueue Time', dataIndex: 'enqueued_at' }),
];
const workersColumns = [Columns.custom(
value => (
<span><Badge status={{ busy: 'processing',
idle: 'default',
started: 'success',
suspended: 'warning' }[value]}
/> {value}
</span>
), { title: 'State', dataIndex: 'state' },
)].concat(map(['Hostname', 'PID', 'Name', 'Queues', 'Current Job', 'Successful Jobs', 'Failed Jobs'],
c => ({ title: c, dataIndex: c.toLowerCase().replace(/\s/g, '_') }))).concat([
Columns.dateTime({ title: 'Birth Date', dataIndex: 'birth_date' }),
Columns.duration({ title: 'Total Working Time', dataIndex: 'total_working_time' }),
]);
const queuesColumns = map(
['Name', 'Started', 'Queued'],
c => ({ title: c, dataIndex: c.toLowerCase() }),
);
const TablePropTypes = {
loading: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export function WorkersTable({ loading, items }) {
return (
<Table
loading={loading}
columns={workersColumns}
rowKey="name"
dataSource={items}
/>
);
}
WorkersTable.propTypes = TablePropTypes;
export function QueuesTable({ loading, items }) {
return (
<Table
loading={loading}
columns={queuesColumns}
rowKey="name"
dataSource={items}
/>
);
}
QueuesTable.propTypes = TablePropTypes;
export function OtherJobsTable({ loading, items }) {
return (
<Table
loading={loading}
columns={otherJobsColumns}
rowKey="id"
dataSource={items}
/>
);
}
OtherJobsTable.propTypes = TablePropTypes;

View File

@@ -1,27 +0,0 @@
<div class="p-5">
<h4>Notifications</h4>
<div>
<ui-select ng-model="newSubscription.destination" ng-disabled="destinations.length == 0">
<ui-select-match><span ng-bind-html="destinationsDisplay($select.selected)"></span></ui-select-match>
<ui-select-choices repeat="d in destinations">
<span ng-bind-html="destinationsDisplay(d)"></span>
</ui-select-choices>
</ui-select>
</div>
<div class="m-t-5">
<button class="btn btn-default" ng-click="saveSubscriber()" ng-disabled="destinations.length == 0" style="width:50%;">Add</button>
<span class="pull-right m-t-5">
<a href="destinations/new" ng-if="currentUser.isAdmin">Create New Destination</a>
</span>
</div>
<hr/>
<div>
<div class="list-group-item" ng-repeat="subscriber in subscribers">
<span ng-bind-html="destinationsDisplay(subscriber)"></span>
<button class="btn btn-xs btn-danger pull-right" ng-click="unsubscribe(subscriber)" ng-if="currentUser.isAdmin || currentUser.id == subscriber.user.id">Remove</button>
</div>
</div>
</div>

View File

@@ -1,116 +0,0 @@
import { includes, without, compact } from 'lodash';
import notification from '@/services/notification';
import template from './alert-subscriptions.html';
function controller($scope, $q, $sce, currentUser, AlertSubscription, Destination) {
'ngInject';
$scope.newSubscription = {};
$scope.subscribers = [];
$scope.destinations = [];
$scope.currentUser = currentUser;
$q
.all([
Destination.query().$promise,
AlertSubscription.query({ alertId: $scope.alertId }).$promise,
])
.then((responses) => {
const destinations = responses[0];
const subscribers = responses[1];
const mapF = s => s.destination && s.destination.id;
const subscribedDestinations = compact(subscribers.map(mapF));
const subscribedUsers = compact(subscribers.map(s => !s.destination && s.user.id));
$scope.destinations = destinations.filter(d => !includes(subscribedDestinations, d.id));
if (!includes(subscribedUsers, currentUser.id)) {
$scope.destinations.unshift({ user: { name: currentUser.name } });
}
$scope.newSubscription.destination = $scope.destinations[0];
$scope.subscribers = subscribers;
});
$scope.destinationsDisplay = (d) => {
if (!d) {
return '';
}
let destination = d;
if (d.destination) {
destination = destination.destination;
} else if (destination.user) {
destination = {
name: `${d.user.name} (Email)`,
icon: 'fa-envelope',
type: 'user',
};
}
return $sce.trustAsHtml(`<i class="fa ${destination.icon}"></i>&nbsp;${destination.name}`);
};
$scope.saveSubscriber = () => {
const sub = new AlertSubscription({ alert_id: $scope.alertId });
if ($scope.newSubscription.destination.id) {
sub.destination_id = $scope.newSubscription.destination.id;
}
sub.$save(
() => {
notification.success('Subscribed.');
$scope.subscribers.push(sub);
$scope.destinations = without($scope.destinations, $scope.newSubscription.destination);
if ($scope.destinations.length > 0) {
$scope.newSubscription.destination = $scope.destinations[0];
} else {
$scope.newSubscription.destination = undefined;
}
},
() => {
notification.error('Failed saving subscription.');
},
);
};
$scope.unsubscribe = (subscriber) => {
const destination = subscriber.destination;
const user = subscriber.user;
subscriber.$delete(
() => {
notification.success('Unsubscribed');
$scope.subscribers = without($scope.subscribers, subscriber);
if (destination) {
$scope.destinations.push(destination);
} else if (user.id === currentUser.id) {
$scope.destinations.push({ user: { name: currentUser.name } });
}
if ($scope.destinations.length === 1) {
$scope.newSubscription.destination = $scope.destinations[0];
}
},
() => {
notification.error('Failed unsubscribing.');
},
);
};
}
export default function init(ngModule) {
ngModule.directive('alertSubscriptions', () => ({
restrict: 'E',
replace: true,
scope: {
alertId: '=',
},
template,
controller,
}));
}
init.init = true;

View File

@@ -0,0 +1,257 @@
/* eslint-disable no-template-curly-in-string */
import React, { useRef } from 'react';
import { react2angular } from 'react2angular';
import Dropdown from 'antd/lib/dropdown';
import Button from 'antd/lib/button';
import Icon from 'antd/lib/icon';
import Menu from 'antd/lib/menu';
import Input from 'antd/lib/input';
import Tooltip from 'antd/lib/tooltip';
import FavoritesDropdown from './components/FavoritesDropdown';
import HelpTrigger from '@/components/HelpTrigger';
import CreateDashboardDialog from '@/components/dashboards/CreateDashboardDialog';
import { currentUser, Auth, clientConfig } from '@/services/auth';
import { $location, $route } from '@/services/ng';
import { Dashboard } from '@/services/dashboard';
import { Query } from '@/services/query';
import frontendVersion from '@/version.json';
import logoUrl from '@/assets/images/redash_icon_small.png';
import './AppHeader.less';
function onSearch(q) {
$location.path('/queries').search({ q });
$route.reload();
}
function DesktopNavbar() {
return (
<div className="app-header" data-platform="desktop">
<div>
<Menu mode="horizontal" selectable={false}>
{currentUser.hasPermission('list_dashboards') && (
<Menu.Item key="dashboards" className="dropdown-menu-item">
<Button href="dashboards">Dashboards</Button>
<FavoritesDropdown fetch={Dashboard.favorites} urlTemplate="dashboard/${slug}" />
</Menu.Item>
)}
{currentUser.hasPermission('view_query') && (
<Menu.Item key="queries" className="dropdown-menu-item">
<Button href="queries">Queries</Button>
<FavoritesDropdown fetch={Query.favorites} urlTemplate="queries/${id}" />
</Menu.Item>
)}
{currentUser.hasPermission('list_alerts') && (
<Menu.Item key="alerts">
<Button href="alerts">Alerts</Button>
</Menu.Item>
)}
</Menu>
<Dropdown
trigger={['click']}
overlay={(
<Menu>
{currentUser.hasPermission('create_query') && (
<Menu.Item key="new-query">
<a href="queries/new">New Query</a>
</Menu.Item>
)}
{currentUser.hasPermission('create_dashboard') && (
<Menu.Item key="new-dashboard">
<a onMouseUp={CreateDashboardDialog.showModal}>New Dashboard</a>
</Menu.Item>
)}
<Menu.Item key="new-alert">
<a href="alerts/new">New Alert</a>
</Menu.Item>
</Menu>
)}
>
<Button type="primary" data-test="CreateButton">
Create <Icon type="down" />
</Button>
</Dropdown>
</div>
<div className="header-logo">
<a href="./">
<img src={logoUrl} alt="Redash" />
</a>
</div>
<div>
<Input.Search
className="searchbar"
placeholder="Search queries..."
data-test="AppHeaderSearch"
onSearch={onSearch}
/>
<Menu mode="horizontal" selectable={false}>
<Menu.Item key="help">
<HelpTrigger type="HOME" className="menu-item-button" />
</Menu.Item>
{currentUser.isAdmin && (
<Menu.Item key="settings">
<Tooltip title="Settings">
<Button href="data_sources" className="menu-item-button">
<i className="fa fa-sliders" />
</Button>
</Tooltip>
</Menu.Item>
)}
<Menu.Item key="profile">
<Dropdown
overlayStyle={{ minWidth: 200 }}
placement="bottomRight"
trigger={['click']}
overlay={(
<Menu>
<Menu.Item key="profile">
<a href="users/me">Edit Profile</a>
</Menu.Item>
{currentUser.hasPermission('super_admin') && (
<Menu.Divider />
)}
{currentUser.isAdmin && (
<Menu.Item key="datasources">
<a href="data_sources">Data Sources</a>
</Menu.Item>
)}
{currentUser.hasPermission('list_users') && (
<Menu.Item key="groups">
<a href="groups">Groups</a>
</Menu.Item>
)}
{currentUser.hasPermission('list_users') && (
<Menu.Item key="users">
<a href="users">Users</a>
</Menu.Item>
)}
<Menu.Item key="snippets">
<a href="query_snippets">Query Snippets</a>
</Menu.Item>
{currentUser.hasPermission('list_users') && (
<Menu.Item key="destinations">
<a href="destinations">Alert Destinations</a>
</Menu.Item>
)}
{currentUser.hasPermission('super_admin') && (
<Menu.Divider />
)}
{currentUser.hasPermission('super_admin') && (
<Menu.Item key="status">
<a href="admin/status">System Status</a>
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item key="logout" onClick={() => Auth.logout()}>Log out</Menu.Item>
<Menu.Divider />
<Menu.Item key="version" disabled>
Version: {clientConfig.version}
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
{clientConfig.newVersionAvailable && currentUser.hasPermission('super_admin') && (
<Tooltip title="Update Available" placement="rightTop">
{' '}
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<a href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
<i className="fa fa-arrow-circle-down" />
</a>
</Tooltip>
)}
</Menu.Item>
</Menu>
)}
>
<Button data-test="ProfileDropdown" className="profile-dropdown">
<img src={currentUser.profile_image_url} alt={currentUser.name} />
<span>{currentUser.name}</span>
<Icon type="down" />
</Button>
</Dropdown>
</Menu.Item>
</Menu>
</div>
</div>
);
}
function MobileNavbar() {
const ref = useRef();
return (
<div className="app-header" data-platform="mobile" ref={ref}>
<div className="header-logo">
<a href="./">
<img src={logoUrl} alt="Redash" />
</a>
</div>
<div>
<Dropdown
overlayStyle={{ minWidth: 200 }}
trigger={['click']}
getPopupContainer={() => ref.current} // so the overlay menu stays with the fixed header when page scrolls
overlay={(
<Menu mode="vertical" selectable={false}>
{currentUser.hasPermission('list_dashboards') && (
<Menu.Item key="dashboards">
<a href="dashboards">Dashboards</a>
</Menu.Item>
)}
{currentUser.hasPermission('view_query') && (
<Menu.Item key="queries">
<a href="queries">Queries</a>
</Menu.Item>
)}
{currentUser.hasPermission('list_alerts') && (
<Menu.Item key="alerts">
<a href="alerts">Alerts</a>
</Menu.Item>
)}
<Menu.Item key="profile">
<a href="users/me">Edit Profile</a>
</Menu.Item>
<Menu.Divider />
{currentUser.isAdmin && (
<Menu.Item key="settings">
<a href="data_sources">Settings</a>
</Menu.Item>
)}
{currentUser.hasPermission('super_admin') && (
<Menu.Item key="status">
<a href="admin/status">System Status</a>
</Menu.Item>
)}
{currentUser.hasPermission('super_admin') && (
<Menu.Divider />
)}
<Menu.Item key="help">
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<a href="https://redash.io/help" target="_blank" rel="noopener">Help</a>
</Menu.Item>
<Menu.Item key="logout" onClick={() => Auth.logout()}>Log out</Menu.Item>
</Menu>
)}
>
<Button><Icon type="menu" /></Button>
</Dropdown>
</div>
</div>
);
}
export function AppHeader() {
return (
<nav className="app-header-wrapper">
<DesktopNavbar />
<MobileNavbar />
</nav>
);
}
export default function init(ngModule) {
ngModule.component('appHeader', react2angular(AppHeader));
}
init.init = true;

View File

@@ -0,0 +1,207 @@
@mobileBreakpoint: ~"(max-width: 767px)";
nav .app-header {
height: 49px;
padding-bottom: 1px;
box-sizing: content-box;
display: flex;
justify-content: space-between;
margin-bottom: 10px;
background: white;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, .15);
.darker {
color: #333 !important;
&:hover {
color: #2196F3 !important;
}
}
& > * {
display: flex;
align-items: center;
}
&[data-platform="mobile"] {
display: none;
}
.menu-item-button {
padding: 0 15px;
font-size: 18px;
.darker();
}
.ant-menu-root {
margin: 0 10px;
line-height: 50px;
height: 50px;
border-bottom: 0;
}
.ant-btn {
font-weight: 500;
.anticon {
margin-right: 0;
}
}
&[data-platform="desktop"] .ant-btn:not(.ant-btn-primary) {
border: 0;
box-shadow: none;
height: 40px;
line-height: 40px;
background-color: transparent; //so it doesn't interfere with click animation of adjacent buttons
.darker();
}
.ant-menu-item {
padding: 0;
height: 52px;
display: inline-flex;
align-items: center;
.anticon-down {
font-size: 13px !important;
transform: none;
position: relative;
top: 2px;
svg {
transition: transform .2s cubic-bezier(.75,0,.25,1);
}
}
.ant-dropdown-open .anticon-down svg,
.anticon-down.ant-dropdown-open svg {
transform: rotate(180deg);
}
}
.dropdown-menu-item {
.ant-btn {
padding-right: 5px;
padding-left: 5px;
margin-right: 30px;
margin-left: 10px;
position: relative;
z-index: 1;
}
// this is a trick to get the dropdown menu to be placed at the bottom left
// of the menu item and not the dropdown trigger
.ant-dropdown-trigger {
position: absolute;
top: 5px;
right: 0;
left: 10px;
bottom: 5px;
text-align: right;
padding-top: 14px;
padding-right: 10px;
margin-right: 0;
user-select: none; // or else double clicking it causes the header logo to get selected
.darker();
}
}
.header-logo img {
height: 40px;
width: 40px;
}
.searchbar {
width: 185px;
}
.profile-dropdown {
display: flex;
align-items: center;
span {
max-width: 130px; // arbitrary, prevents layout mess up if username long
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
img {
height: 20px;
width: 20px;
border-radius: 50%;
margin-right: 5px;
}
}
@media (max-width: 960px) {
.ant-btn,
.menu-item-button {
padding: 0 10px;
}
.ant-menu-root {
margin: 0 5px;
}
.profile-dropdown {
span {
display: none;
}
img {
margin-right: 0;
}
}
}
@media (max-width: 800px) {
.searchbar {
width: 140px;
}
}
@media @mobileBreakpoint {
&[data-platform="desktop"] {
display: none;
}
&[data-platform="mobile"] {
display: flex;
padding: 0 15px;
position: fixed;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
z-index: 1000;
}
}
}
@media @mobileBreakpoint {
.app-header-wrapper {
margin-top: 59px !important; // compensate for app header fixed position
}
}
.update-available {
display: inline !important;
.fa {
color: #52c41a;
vertical-align: text-bottom;
font-size: 16px;
}
}
.ant-dropdown-menu-item .help-trigger {
display: inline;
color: #2196F3;
vertical-align: bottom;
}
.ant-dropdown-menu.favorites-dropdown {
margin-left: -10px;
}

View File

@@ -1,13 +0,0 @@
.menu-search input[type="text"] {
height: 30px;
}
.dropdown-menu__version {
padding: 5px 10px 8px 17px;
}
.update-available .fa {
color: #52c41a;
vertical-align: bottom;
font-size: 16px !important;
}

View File

@@ -1,233 +0,0 @@
<nav class="navbar navbar-default app-header" role="navigation">
<div class="container">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle"
ng-click="isNavOpen = !isNavOpen"
>
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<!-- REDASH LOGO -->
<a class="navbar-brand" ng-href="{{ $ctrl.basePath }}"
><img ng-src="{{ $ctrl.logoUrl }}"
/></a>
</div>
<div class="collapse navbar-collapse" uib-collapse="!isNavOpen">
<!-- Main Left Nav-->
<ul class="nav navbar-nav nav__main">
<li
class="dropdown btn-group"
ng-show="$ctrl.showDashboardsMenu"
uib-dropdown
>
<a class="btn" href="dashboards">Dashboards</a>
<a type="button" class="btn hidden-xs" uib-dropdown-toggle>
<span class="caret caret--nav"></span>
</a>
<ul class="dropdown-menu" uib-dropdown-menu>
<li ng-if="$ctrl.dashboards.length == 0">
<a>
<em>
<span class="btn-favourite">
<i class="fa fa-star" aria-hidden="true"></i>
</span>
Favorite Dashboards will appear here
</em>
</a>
</li>
<li ng-repeat="dashboard in $ctrl.dashboards">
<a href="dashboard/{{ dashboard.slug }}">
{{ dashboard.name }}
</a>
</li>
</ul>
</li>
<li
class="dropdown btn-group"
ng-show="$ctrl.showQueriesMenu"
uib-dropdown
>
<a class="btn" href="queries">Queries</a>
<a type="button" class="btn hidden-xs" uib-dropdown-toggle>
<span class="caret caret--nav"></span>
</a>
<ul class="dropdown-menu" uib-dropdown-menu>
<li ng-if="$ctrl.queries.length == 0">
<a>
<em>
<span class="btn-favourite">
<i class="fa fa-star" aria-hidden="true"></i>
</span>
Favorite Queries will appear here
</em>
</a>
</li>
<li ng-repeat="query in $ctrl.queries">
<a href="queries/{{ query.id }}">
{{ query.name }}
</a>
</li>
</ul>
</li>
<li ng-if="$ctrl.showAlertsLink">
<a href="alerts">Alerts</a>
</li>
</ul>
<!-- Add New Button -->
<div
class="btn-group navbar-btn navbar-left btn__new hidden-xs"
uib-dropdown
is-open="status.isopen"
>
<button
id="create-button"
data-test="CreateButton"
type="button"
class="btn btn-primary btn--create"
uib-dropdown-toggle
ng-disabled="disabled"
>
Create <span class="caret caret--nav"></span>
</button>
<ul
class="dropdown-menu"
uib-dropdown-menu
role="menu"
aria-labelledby="create-button"
>
<li role="menuitem" ng-show="$ctrl.showNewQueryMenu">
<a href="queries/new">Query</a>
</li>
<li role="menuitem">
<a
ng-show="$ctrl.currentUser.hasPermission('create_dashboard')"
ng-click="$ctrl.newDashboard()"
>Dashboard</a
>
</li>
<li role="menuitem"><a href="alerts/new">Alert</a></li>
</ul>
</div>
<!-- Profile -->
<ul class="nav navbar-nav navbar-right">
<li>
<help-trigger
type="'HOME'"
class-name="'navbar-link-ANGULAR_REMOVE_ME'"
></help-trigger>
</li>
<li ng-show="$ctrl.currentUser.isAdmin">
<a href="data_sources" title="Settings"
><i class="fa fa-sliders" aria-hidden="true"></i
></a>
</li>
<!--<li ng-show="$ctrl.showSettingsMenu">-->
<!--<a href="users" title="Settings"><i class="fa fa-cog"></i></a>-->
<!--</li>-->
<li class="dropdown" uib-dropdown>
<a
href="#"
class="dropdown-toggle dropdown--profile"
uib-dropdown-toggle
data-test="ProfileDropdown"
>
<img
ng-src="{{ $ctrl.currentUser.profile_image_url }}"
class="profile__image--navbar"
width="20"/>
<span
class="dropdown--profile__username"
ng-bind="$ctrl.currentUser.name"
></span>
<span class="caret caret--nav"></span
></a>
<ul class="dropdown-menu dropdown-menu--profile">
<li>
<a ng-href="users/me">Edit Profile</a>
</li>
<li
class="divider"
ng-if="$ctrl.currentUser.hasPermission('super_admin')"
></li>
<li ng-show="$ctrl.currentUser.isAdmin">
<a href="data_sources" title="Data Sources">Data Sources</a>
</li>
<li ng-show="$ctrl.showSettingsMenu">
<a href="groups" title="Settings">Groups</a>
</li>
<li ng-show="$ctrl.showSettingsMenu">
<a href="users" title="Settings">Users</a>
</li>
<li>
<a ng-href="query_snippets">Query Snippets</a>
</li>
<li ng-show="$ctrl.showSettingsMenu">
<a href="destinations" title="Settings">Alert Destinations</a>
</li>
<li
ng-if="$ctrl.currentUser.hasPermission('super_admin')"
class="divider"
></li>
<li ng-if="$ctrl.currentUser.hasPermission('super_admin')">
<a href="admin/status">System Status</a>
</li>
<li class="divider"></li>
<li>
<a ng-click="$ctrl.logout()">Log out</a>
</li>
<li class="divider"></li>
<li class="dropdown-menu__version">
Version: {{ $ctrl.backendVersion }}
<span ng-if="$ctrl.frontendVersion !== $ctrl.backendVersion">
({{ $ctrl.frontendVersion.substring(0, 8) }})
</span>
<span
class="update-available"
ng-if="$ctrl.currentUser.hasPermission('super_admin') && $ctrl.newVersionAvailable"
>
<a href="https://version.redash.io/" target="_blank">
<i class="fa fa-arrow-circle-down"></i>
</a>
</span>
</li>
</ul>
</li>
</ul>
<!-- Search -->
<form
class="navbar-form navbar-right"
role="search"
ng-submit="$ctrl.searchQueries()"
>
<div class="input-group menu-search">
<input
type="text"
ng-model="$ctrl.searchTerm"
class="form-control navbar__search__input"
placeholder="Search queries..."
data-test="AppHeaderSearch"
/>
<span class="input-group-btn">
<button type="submit" class="btn btn-default">
<span class="zmdi zmdi-search"></span>
</button>
</span>
</div>
</form>
</div>
</div>
</nav>

View File

@@ -0,0 +1,69 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
import { isEmpty, template } from 'lodash';
import Dropdown from 'antd/lib/dropdown';
import Icon from 'antd/lib/icon';
import Menu from 'antd/lib/menu';
import HelpTrigger from '@/components/HelpTrigger';
export default function FavoritesDropdown({ fetch, urlTemplate }) {
const [items, setItems] = useState();
const [loading, setLoading] = useState(false);
const noItems = isEmpty(items);
const urlCompiled = useMemo(() => template(urlTemplate), [urlTemplate]);
const fetchItems = useCallback(() => {
setLoading(true);
fetch().$promise
.then(({ results }) => {
setItems(results);
})
.finally(() => {
setLoading(false);
});
}, [fetch]);
// fetch items on init
useEffect(fetchItems, []);
// fetch items on click
const onVisibleChange = visible => visible && fetchItems();
const menu = (
<Menu className="favorites-dropdown">
{noItems ? (
<Menu.Item>
<span className="btn-favourite m-r-5">
<i className="fa fa-star" />
</span>
No favorites selected yet <HelpTrigger type="FAVORITES" />
</Menu.Item>
) : (
items.map(item => (
<Menu.Item key={item.id}>
<a href={urlCompiled(item)}>
<span className="btn-favourite m-r-5">
<i className="fa fa-star" />
</span>
{item.name}
</a>
</Menu.Item>
))
)}
</Menu>
);
return (
<Dropdown disabled={loading} trigger={['click']} placement="bottomLeft" onVisibleChange={onVisibleChange} overlay={menu}>
{loading ? <Icon type="loading" spin /> : <Icon type="down" />}
</Dropdown>
);
}
FavoritesDropdown.propTypes = {
fetch: PropTypes.func.isRequired,
urlTemplate: PropTypes.string.isRequired,
};

View File

@@ -1,58 +0,0 @@
import debug from 'debug';
import CreateDashboardDialog from '@/components/dashboards/CreateDashboardDialog';
import logoUrl from '@/assets/images/redash_icon_small.png';
import frontendVersion from '@/version.json';
import template from './app-header.html';
import './app-header.css';
const logger = debug('redash:appHeader');
function controller($rootScope, $location, $route, $uibModal, Auth, currentUser, clientConfig, Dashboard, Query) {
this.logoUrl = logoUrl;
this.basePath = clientConfig.basePath;
this.currentUser = currentUser;
this.showQueriesMenu = currentUser.hasPermission('view_query');
this.showAlertsLink = currentUser.hasPermission('list_alerts');
this.showNewQueryMenu = currentUser.hasPermission('create_query');
this.showSettingsMenu = currentUser.hasPermission('list_users');
this.showDashboardsMenu = currentUser.hasPermission('list_dashboards');
this.frontendVersion = frontendVersion;
this.backendVersion = clientConfig.version;
this.newVersionAvailable = clientConfig.newVersionAvailable && currentUser.isAdmin;
this.reload = () => {
logger('Reloading dashboards and queries.');
Dashboard.favorites().$promise.then((data) => {
this.dashboards = data.results;
});
Query.favorites().$promise.then((data) => {
this.queries = data.results;
});
};
this.reload();
$rootScope.$on('reloadFavorites', this.reload);
this.newDashboard = () => CreateDashboardDialog.showModal();
this.searchQueries = () => {
$location.path('/queries').search({ q: this.searchTerm });
$route.reload();
};
this.logout = () => {
Auth.logout();
};
}
export default function init(ngModule) {
ngModule.component('appHeader', {
template,
controller,
});
}
init.init = true;

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 {

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,19 +1,10 @@
@import '../assets/less/inc/variables';
// ANGULAR_REMOVE_ME
color-box {
vertical-align: text-bottom;
display: inline;
display: inline-block;
margin-right: 5px;
span {
width: 12px !important;
height: 12px !important;
display: inline-block !important;
margin-right: 5px;
vertical-align: middle;
border: 1px solid rgba(0,0,0,0.1);
}
& ~ span {
& ~ span {
vertical-align: bottom;
color: @input-color;
}
}
}

View File

@@ -5,7 +5,7 @@ import { includes, reduce, some } from 'lodash';
const WIDGET_SELECTOR = '[data-widgetid="{0}"]';
const WIDGET_CONTENT_SELECTOR = [
'.widget-header', // header
'visualization-renderer', // visualization
'.visualization-renderer', // visualization
'.scrollbox .alert', // error state
'.spinner-container', // loading state
'.tile__bottom-control', // footer

View File

@@ -4,10 +4,11 @@ import { chain, cloneDeep, find } from 'lodash';
import { react2angular } from 'react2angular';
import cx from 'classnames';
import { Responsive, WidthProvider } from 'react-grid-layout';
import { DashboardWidget } from '@/components/dashboards/widget';
import { VisualizationWidget, TextboxWidget, RestrictedWidget } from '@/components/dashboards/dashboard-widget';
import { FiltersType } from '@/components/Filters';
import cfg from '@/config/dashboard-grid-options';
import AutoHeightController from './AutoHeightController';
import { WidgetTypeEnum } from '@/services/widget';
import 'react-grid-layout/css/styles.css';
import './dashboard-grid.less';
@@ -41,16 +42,22 @@ class DashboardGrid extends React.Component {
widgets: PropTypes.arrayOf(WidgetType).isRequired,
filters: FiltersType,
onBreakpointChange: PropTypes.func,
onLoadWidget: PropTypes.func,
onRefreshWidget: PropTypes.func,
onRemoveWidget: PropTypes.func,
onLayoutChange: PropTypes.func,
onParameterMappingsChange: PropTypes.func,
};
static defaultProps = {
isPublic: false,
filters: [],
onLoadWidget: () => {},
onRefreshWidget: () => {},
onRemoveWidget: () => {},
onLayoutChange: () => {},
onBreakpointChange: () => {},
onParameterMappingsChange: () => {},
};
static normalizeFrom(widget) {
@@ -168,7 +175,8 @@ class DashboardGrid extends React.Component {
render() {
const className = cx('dashboard-wrapper', this.props.isEditing ? 'editing-mode' : 'preview-mode');
const { onRemoveWidget, dashboard, widgets } = this.props;
const { onLoadWidget, onRefreshWidget, onRemoveWidget,
onParameterMappingsChange, filters, dashboard, isPublic, widgets } = this.props;
return (
<div className={className}>
@@ -186,23 +194,37 @@ class DashboardGrid extends React.Component {
onBreakpointChange={this.onBreakpointChange}
breakpoints={{ [MULTI]: cfg.mobileBreakPoint, [SINGLE]: 0 }}
>
{widgets.map(widget => (
<div
key={widget.id}
data-grid={DashboardGrid.normalizeFrom(widget)}
data-widgetid={widget.id}
data-test={`WidgetId${widget.id}`}
className={cx('dashboard-widget-wrapper', { 'widget-auto-height-enabled': this.autoHeightCtrl.exists(widget.id) })}
>
<DashboardWidget
widget={widget}
dashboard={dashboard}
filters={this.props.filters}
deleted={() => onRemoveWidget(widget.id)}
public={this.props.isPublic}
/>
</div>
))}
{widgets.map((widget) => {
const widgetProps = {
widget,
filters,
isPublic,
canEdit: dashboard.canEdit(),
onDelete: () => onRemoveWidget(widget.id),
};
const { type } = widget;
return (
<div
key={widget.id}
data-grid={DashboardGrid.normalizeFrom(widget)}
data-widgetid={widget.id}
data-test={`WidgetId${widget.id}`}
className={cx('dashboard-widget-wrapper', { 'widget-auto-height-enabled': this.autoHeightCtrl.exists(widget.id) })}
>
{type === WidgetTypeEnum.VISUALIZATION && (
<VisualizationWidget
{...widgetProps}
dashboard={dashboard}
onLoad={() => onLoadWidget(widget)}
onRefresh={() => onRefreshWidget(widget)}
onParameterMappingsChange={onParameterMappingsChange}
/>
)}
{type === WidgetTypeEnum.TEXTBOX && <TextboxWidget {...widgetProps} />}
{type === WidgetTypeEnum.RESTRICTED && <RestrictedWidget widget={widget} />}
</div>
);
})}
</ResponsiveGridLayout>
</div>
);

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from 'antd/lib/button';
import Modal from 'antd/lib/modal';
import { VisualizationRenderer } from '@/visualizations/VisualizationRenderer';
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper';
import { VisualizationName } from '@/visualizations/VisualizationName';
function ExpandedWidgetDialog({ dialog, widget }) {
return (
<Modal
{...dialog.props}
title={(
<>
<VisualizationName visualization={widget.visualization} />{' '}
<span>{widget.getQuery().name}</span>
</>
)}
width="95%"
footer={(<Button onClick={dialog.dismiss}>Close</Button>)}
>
<VisualizationRenderer
visualization={widget.visualization}
queryResult={widget.getQueryResult()}
context="widget"
/>
</Modal>
);
}
ExpandedWidgetDialog.propTypes = {
dialog: DialogPropType.isRequired,
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
export default wrapDialog(ExpandedWidgetDialog);

View File

@@ -14,7 +14,6 @@ import './TextboxDialog.less';
class TextboxDialog extends React.Component {
static propTypes = {
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dialog: DialogPropType.isRequired,
onConfirm: PropTypes.func.isRequired,
text: PropTypes.string,

View File

@@ -5,3 +5,32 @@
}
}
}
// react-grid-layout overrides
.react-grid-item {
// placeholder color
&.react-grid-placeholder {
border-radius: 3px;
background-color: #E0E6EB;
opacity: 0.5;
}
// resize placeholder behind widget, the lib's default is above 🤷‍♂️
&.resizing {
z-index: 3;
}
// auto-height animation
&.cssTransforms:not(.resizing) {
transition-property: transform, height; // added ", height"
}
// resize handle size
& > .react-resizable-handle::after {
width: 11px;
height: 11px;
right: 5px;
bottom: 5px;
}
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import Widget from './Widget';
function RestrictedWidget(props) {
return (
<Widget {...props} className="d-flex justify-content-center align-items-center widget-restricted">
<div className="t-body scrollbox">
<div className="text-center">
<h1><span className="zmdi zmdi-lock" /></h1>
<p className="text-muted">
This widget requires access to a data source you don&apos;t have access to.
</p>
</div>
</div>
</Widget>
);
}
export default RestrictedWidget;

View File

@@ -0,0 +1,50 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { markdown } from 'markdown';
import Menu from 'antd/lib/menu';
import HtmlContent from '@/components/HtmlContent';
import TextboxDialog from '@/components/dashboards/TextboxDialog';
import Widget from './Widget';
function TextboxWidget(props) {
const { widget, canEdit } = props;
const [text, setText] = useState(widget.text);
const editTextBox = () => {
TextboxDialog.showModal({
text: widget.text,
onConfirm: (newText) => {
widget.text = newText;
setText(newText);
return widget.save();
},
});
};
const TextboxMenuOptions = [
<Menu.Item key="edit" onClick={editTextBox}>Edit</Menu.Item>,
];
if (!widget.width) {
return null;
}
return (
<Widget {...props} menuOptions={canEdit ? TextboxMenuOptions : null} className="widget-text">
<HtmlContent className="body-row-auto scrollbox t-body p-15 markdown">
{markdown.toHTML(text || '')}
</HtmlContent>
</Widget>
);
}
TextboxWidget.propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool,
};
TextboxWidget.defaultProps = {
canEdit: false,
};
export default TextboxWidget;

View File

@@ -0,0 +1,359 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import { compact, isEmpty, invoke } from "lodash";
import { markdown } from "markdown";
import cx from "classnames";
import Menu from "antd/lib/menu";
import { currentUser } from "@/services/auth";
import recordEvent from "@/services/recordEvent";
import { formatDateTime } from "@/filters/datetime";
import HtmlContent from "@/components/HtmlContent";
import { Parameters } from "@/components/Parameters";
import { TimeAgo } from "@/components/TimeAgo";
import { Timer } from "@/components/Timer";
import { Moment } from "@/components/proptypes";
import QueryLink from "@/components/QueryLink";
import { FiltersType } from "@/components/Filters";
import ExpandedWidgetDialog from "@/components/dashboards/ExpandedWidgetDialog";
import EditParameterMappingsDialog from "@/components/dashboards/EditParameterMappingsDialog";
import { VisualizationRenderer } from "@/visualizations/VisualizationRenderer";
import Widget from "./Widget";
function visualizationWidgetMenuOptions({
widget,
canEditDashboard,
onParametersEdit,
}) {
const canViewQuery = currentUser.hasPermission("view_query");
const canEditParameters =
canEditDashboard && !isEmpty(invoke(widget, "query.getParametersDefs"));
const widgetQueryResult = widget.getQueryResult();
const isQueryResultEmpty =
!widgetQueryResult ||
!widgetQueryResult.isEmpty ||
widgetQueryResult.isEmpty();
const downloadLink = (fileType) =>
widgetQueryResult.getLink(widget.getQuery().id, fileType);
const downloadName = (fileType) =>
widgetQueryResult.getName(widget.getQuery().name, fileType);
return compact([
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? (
<a
href={downloadLink("csv")}
download={downloadName("csv")}
target="_self"
>
Download as CSV File
</a>
) : (
"Download as CSV File"
)}
</Menu.Item>,
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? (
<a
href={downloadLink("xlsx")}
download={downloadName("xlsx")}
target="_self"
>
Download as Excel File
</a>
) : (
"Download as Excel File"
)}
</Menu.Item>,
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
canViewQuery && (
<Menu.Item key="view_query">
<a href={widget.getQuery().getUrl(true, widget.visualization.id)}>
View Query
</a>
</Menu.Item>
),
canEditParameters && (
<Menu.Item key="edit_parameters" onClick={onParametersEdit}>
Edit Parameters
</Menu.Item>
),
]);
}
function RefreshIndicator({ refreshStartedAt }) {
return (
<div className="refresh-indicator">
<div className="refresh-icon">
<i className="zmdi zmdi-refresh zmdi-hc-spin" />
</div>
<Timer from={refreshStartedAt} />
</div>
);
}
RefreshIndicator.propTypes = { refreshStartedAt: Moment };
RefreshIndicator.defaultProps = { refreshStartedAt: null };
function VisualizationWidgetHeader({
widget,
refreshStartedAt,
parameters,
onParametersUpdate,
}) {
const canViewQuery = currentUser.hasPermission("view_query");
const queryResult = widget.getQueryResult();
const errorData = queryResult && queryResult.getErrorData();
return (
<>
<RefreshIndicator refreshStartedAt={refreshStartedAt} />
<div className="t-header widget clearfix">
<div className="th-title">
<p>
<QueryLink
query={widget.getQuery()}
visualization={widget.visualization}
readOnly={!canViewQuery}
/>
</p>
<HtmlContent className="text-muted markdown query--description">
{markdown.toHTML(widget.getQuery().description || "")}
</HtmlContent>
</div>
</div>
{!isEmpty(parameters) && (
<div className="m-b-5">
<Parameters
parameters={parameters}
queryResultErrorData={errorData}
onValuesChange={onParametersUpdate}
/>
</div>
)}
</>
);
}
VisualizationWidgetHeader.propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
refreshStartedAt: Moment,
parameters: PropTypes.arrayOf(PropTypes.object),
onParametersUpdate: PropTypes.func,
};
VisualizationWidgetHeader.defaultProps = {
refreshStartedAt: null,
onParametersUpdate: () => {},
parameters: [],
};
function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) {
const widgetQueryResult = widget.getQueryResult();
const updatedAt = invoke(widgetQueryResult, "getUpdatedAt");
const [refreshClickButtonId, setRefreshClickButtonId] = useState();
const refreshWidget = (buttonId) => {
if (!refreshClickButtonId) {
setRefreshClickButtonId(buttonId);
onRefresh().finally(() => setRefreshClickButtonId(null));
}
};
return (
<>
<span>
{!isPublic && !!widgetQueryResult && (
<a
className="refresh-button hidden-print btn btn-sm btn-default btn-transparent"
onClick={() => refreshWidget(1)}
data-test="RefreshButton"
>
<i
className={cx("zmdi zmdi-refresh", {
"zmdi-hc-spin": refreshClickButtonId === 1,
})}
/>{" "}
<TimeAgo date={updatedAt} />
</a>
)}
<span className="visible-print">
<i className="zmdi zmdi-time-restore" /> {formatDateTime(updatedAt)}
</span>
{isPublic && (
<span className="small hidden-print">
<i className="zmdi zmdi-time-restore" />{" "}
<TimeAgo date={updatedAt} />
</span>
)}
</span>
<span>
{!isPublic && (
<a
className="btn btn-sm btn-default hidden-print btn-transparent btn__refresh"
onClick={() => refreshWidget(2)}
>
<i
className={cx("zmdi zmdi-refresh", {
"zmdi-hc-spin": refreshClickButtonId === 2,
})}
/>
</a>
)}
<a
className="btn btn-sm btn-default hidden-print btn-transparent btn__refresh"
onClick={onExpand}
>
<i className="zmdi zmdi-fullscreen" />
</a>
</span>
</>
);
}
VisualizationWidgetFooter.propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isPublic: PropTypes.bool,
onRefresh: PropTypes.func.isRequired,
onExpand: PropTypes.func.isRequired,
};
VisualizationWidgetFooter.defaultProps = { isPublic: false };
class VisualizationWidget extends React.Component {
static propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
filters: FiltersType,
isPublic: PropTypes.bool,
canEdit: PropTypes.bool,
onLoad: PropTypes.func,
onRefresh: PropTypes.func,
onDelete: PropTypes.func,
onParameterMappingsChange: PropTypes.func,
};
static defaultProps = {
filters: [],
isPublic: false,
canEdit: false,
onLoad: () => {},
onRefresh: () => {},
onDelete: () => {},
onParameterMappingsChange: () => {},
};
constructor(props) {
super(props);
this.state = { localParameters: props.widget.getLocalParameters() };
}
componentDidMount() {
const { widget, onLoad } = this.props;
recordEvent("view", "query", widget.visualization.query.id, {
dashboard: true,
});
recordEvent("view", "visualization", widget.visualization.id, {
dashboard: true,
});
onLoad();
}
expandWidget = () => {
ExpandedWidgetDialog.showModal({ widget: this.props.widget });
};
editParameterMappings = () => {
const { widget, dashboard, onRefresh, onParameterMappingsChange } =
this.props;
EditParameterMappingsDialog.showModal({
dashboard,
widget,
}).result.then((valuesChanged) => {
// refresh widget if any parameter value has been updated
if (valuesChanged) {
onRefresh();
}
onParameterMappingsChange();
this.setState({ localParameters: widget.getLocalParameters() });
});
};
renderVisualization() {
const { widget, filters } = this.props;
const widgetQueryResult = widget.getQueryResult();
const widgetStatus = widgetQueryResult && widgetQueryResult.getStatus();
switch (widgetStatus) {
case "failed":
return (
<div className="body-row-auto scrollbox">
{widgetQueryResult.getError() && (
<div className="alert alert-danger m-5">
Error running query:{" "}
<strong>{widgetQueryResult.getError()}</strong>
</div>
)}
</div>
);
case "done":
return (
<div className="body-row-auto scrollbox">
<VisualizationRenderer
visualization={widget.visualization}
queryResult={widgetQueryResult}
filters={filters}
context="widget"
/>
</div>
);
default:
return (
<div className="body-row-auto spinner-container">
<div className="spinner">
<i className="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x" />
</div>
</div>
);
}
}
render() {
const { widget, isPublic, canEdit, onRefresh } = this.props;
const { localParameters } = this.state;
const widgetQueryResult = widget.getQueryResult();
const isRefreshing =
widget.loading && !!(widgetQueryResult && widgetQueryResult.getStatus());
return (
<Widget
{...this.props}
className="widget-visualization"
menuOptions={visualizationWidgetMenuOptions({
widget,
canEditDashboard: canEdit,
onParametersEdit: this.editParameterMappings,
})}
header={
<VisualizationWidgetHeader
widget={widget}
refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null}
parameters={localParameters}
onParametersUpdate={onRefresh}
/>
}
footer={
<VisualizationWidgetFooter
widget={widget}
isPublic={isPublic}
onRefresh={onRefresh}
onExpand={this.expandWidget}
/>
}
tileProps={{ "data-refreshing": isRefreshing }}
>
{this.renderVisualization()}
</Widget>
);
}
}
export default VisualizationWidget;

View File

@@ -0,0 +1,141 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { isEmpty } from 'lodash';
import Dropdown from 'antd/lib/dropdown';
import Modal from 'antd/lib/modal';
import Menu from 'antd/lib/menu';
import recordEvent from '@/services/recordEvent';
import { Moment } from '@/components/proptypes';
import './Widget.less';
function WidgetDropdownButton({ extraOptions, showDeleteOption, onDelete }) {
const WidgetMenu = (
<Menu data-test="WidgetDropdownButtonMenu">
{extraOptions}
{(showDeleteOption && extraOptions) && <Menu.Divider />}
{showDeleteOption && <Menu.Item onClick={onDelete}>Remove from Dashboard</Menu.Item>}
</Menu>
);
return (
<div className="widget-menu-regular">
<Dropdown
overlay={WidgetMenu}
placement="bottomRight"
trigger={['click']}
>
<a className="action p-l-15 p-r-15" data-test="WidgetDropdownButton">
<i className="zmdi zmdi-more-vert" />
</a>
</Dropdown>
</div>
);
}
WidgetDropdownButton.propTypes = {
extraOptions: PropTypes.node,
showDeleteOption: PropTypes.bool,
onDelete: PropTypes.func,
};
WidgetDropdownButton.defaultProps = {
extraOptions: null,
showDeleteOption: false,
onDelete: () => {},
};
function WidgetDeleteButton({ onClick }) {
return (
<div className="widget-menu-remove">
<a className="action" title="Remove From Dashboard" onClick={onClick} data-test="WidgetDeleteButton">
<i className="zmdi zmdi-close" />
</a>
</div>
);
}
WidgetDeleteButton.propTypes = { onClick: PropTypes.func };
WidgetDeleteButton.defaultProps = { onClick: () => {} };
class Widget extends React.Component {
static propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
className: PropTypes.string,
children: PropTypes.node,
header: PropTypes.node,
footer: PropTypes.node,
canEdit: PropTypes.bool,
isPublic: PropTypes.bool,
refreshStartedAt: Moment,
menuOptions: PropTypes.node,
tileProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onDelete: PropTypes.func,
};
static defaultProps = {
className: '',
children: null,
header: null,
footer: null,
canEdit: false,
isPublic: false,
refreshStartedAt: null,
menuOptions: null,
tileProps: {},
onDelete: () => {},
};
componentDidMount() {
const { widget } = this.props;
recordEvent('view', 'widget', widget.id);
}
deleteWidget = () => {
const { widget, onDelete } = this.props;
Modal.confirm({
title: 'Delete Widget',
content: 'Are you sure you want to remove this widget from the dashboard?',
okText: 'Delete',
okType: 'danger',
onOk: () => widget.delete().then(onDelete),
maskClosable: true,
autoFocusButton: null,
});
};
render() {
const { className, children, header, footer, canEdit, isPublic,
menuOptions, tileProps } = this.props;
const showDropdownButton = !isPublic && (canEdit || !isEmpty(menuOptions));
return (
<div className="widget-wrapper">
<div className={cx('tile body-container', className)} {...tileProps}>
<div className="widget-actions">
{showDropdownButton && (
<WidgetDropdownButton
extraOptions={menuOptions}
showDeleteOption={canEdit}
onDelete={this.deleteWidget}
/>
)}
{canEdit && <WidgetDeleteButton onClick={this.deleteWidget} />}
</div>
<div className="body-row widget-header">
{header}
</div>
{children}
{footer && (
<div className="body-row tile__bottom-control">
{footer}
</div>
)}
</div>
</div>
);
}
}
export default Widget;

View File

@@ -0,0 +1,253 @@
@import '../../../assets/less/inc/variables';
.tile .t-header .th-title a.query-link {
color: rgba(0, 0, 0, 0.5);
}
.th-title p.hidden-print {
margin-bottom: 0;
}
.widget-wrapper {
.widget-actions {
position: absolute;
top: 0;
right: 0;
z-index: 1;
.action {
font-size: 24px;
cursor: pointer;
line-height: 100%;
display: block;
padding: 4px 10px 3px;
}
.action:hover {
background-color: rgba(0, 0, 0, 0.1);
}
}
.parameter-container {
margin: 0 15px;
}
.body-container {
display: flex;
flex-direction: column;
align-items: stretch;
.body-row {
flex: 0 1 auto;
}
.body-row-auto {
flex: 1 1 auto;
}
}
.spinner-container {
position: relative;
.spinner {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
}
.scrollbox:empty {
padding: 0 !important;
font-size: 1px !important;
}
.widget-text {
:first-child {
margin-top: 0;
}
:last-child {
margin-bottom: 0;
}
}
}
.editing-mode {
.widget-menu-regular {
display: none;
}
.widget-menu-remove {
display: block;
}
a.query-link {
pointer-events: none;
cursor: move;
}
.th-title {
cursor: move;
}
.refresh-indicator {
transition-duration: 0s;
.rd-timer {
display: none;
}
.refresh-indicator-mini();
}
}
.refresh-indicator {
font-size: 18px;
color: #86a1af;
transition: all 100ms linear;
transition-delay: 150ms; // waits for widget-menu to fade out before moving back over it
transform: translateX(22px);
position: absolute;
right: 29px;
top: 8px;
display: flex;
flex-direction: row-reverse;
.refresh-icon {
position: relative;
&:before {
content: "";
position: absolute;
top: 0px;
right: 0;
width: 24px;
height: 24px;
background-color: #e8ecf0;
border-radius: 50%;
transition: opacity 100ms linear;
transition-delay: 150ms;
}
i {
height: 24px;
width: 24px;
display: flex;
justify-content: center;
align-items: center;
}
}
.rd-timer {
font-size: 13px;
display: inline-block;
font-variant-numeric: tabular-nums;
opacity: 0;
transform: translateX(-6px);
transition: all 100ms linear;
transition-delay: 150ms;
color: #bbbbbb;
background-color: rgba(255,255,255,.9);
padding-left: 2px;
padding-right: 1px;
margin-right: -4px;
margin-top: 2px;
}
.widget-visualization[data-refreshing="false"] & {
display: none;
}
}
.refresh-indicator-mini() {
font-size: 13px;
transition-delay: 0s;
color: #bbbbbb;
transform: translateY(-4px);
.refresh-icon:before {
transition-delay: 0s;
opacity: 0;
}
.rd-timer {
transition-delay: 0s;
opacity: 1;
transform: translateX(0);
}
}
.tile {
.widget-menu-regular, .btn__refresh {
opacity: 0 !important;
transition: opacity 0.35s ease-in-out;
}
.t-header {
.th-title {
padding-right: 23px; // no overlap on RefreshIndicator
a {
color: fade(@redash-black, 80%);
font-size: 15px;
font-weight: 500;
}
}
.query--description {
font-size: 14px;
line-height: 1.5;
font-style: italic;
p {
margin-bottom: 0;
}
}
}
.t-header.widget {
padding: 15px;
}
&:hover {
.widget-menu-regular, .btn__refresh {
opacity: 1 !important;
transition: opacity 0.35s ease-in-out;
}
.refresh-indicator {
.refresh-indicator-mini();
}
}
.tile__bottom-control {
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
.btn-transparent {
&:first-child {
margin-left: -10px;
}
&:last-child {
margin-right: -10px;
}
}
a {
color: fade(@redash-black, 65%);
&:hover {
color: fade(@redash-black, 95%);
}
}
}
}

View File

@@ -0,0 +1,3 @@
export { default as VisualizationWidget } from './VisualizationWidget';
export { default as TextboxWidget } from './TextboxWidget';
export { default as RestrictedWidget } from './RestrictedWidget';

View File

@@ -1,12 +0,0 @@
<div class="modal-header">
<button type="button" class="close" aria-hidden="true" ng-click="$ctrl.dismiss()">&times;</button>
<div class="visualization-title">
<query-link query="$ctrl.widget.getQuery()" visualization="$ctrl.widget.visualization" readonly="true"></query-link>
</div>
</div>
<div class="modal-body">
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.widget.getQueryResult()" class="t-body"></visualization-renderer>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-click="$ctrl.dismiss()">Close</button>
</div>

View File

@@ -1,8 +0,0 @@
.visualization-title {
font-weight: 500;
font-size: 15px;
}
body.modal-open .dropdown.open {
z-index: 10000;
}

View File

@@ -1,118 +0,0 @@
<div class="widget-wrapper">
<div class="tile body-container widget-visualization" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
ng-switch="$ctrl.widget.getQueryResult().getStatus()" ng-attr-data-refreshing="{{ $ctrl.widget.loading && !!$ctrl.widget.getQueryResult().getStatus() }}">
<div class="body-row widget-header">
<div class="t-header widget clearfix">
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
<div class="actions">
<a ng-click="$ctrl.deleteWidget()" title="Remove From Dashboard"><i class="zmdi zmdi-close"></i></a>
</div>
</div>
<div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public"
uib-dropdown dropdown-append-to-body="true"
>
<div class="actions">
<a data-toggle="dropdown" uib-dropdown-toggle class="p-l-15 p-r-15"><i class="zmdi zmdi-more-vert"></i></a>
</div>
<ul class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'csv')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'csv')}}" target="_self">Download as CSV File</a></li>
<li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'xlsx')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'xlsx')}}" target="_self">Download as Excel File</a></li>
<li ng-if="$ctrl.canViewQuery || ($ctrl.dashboard.canEdit() && $ctrl.hasParameters())" class="divider"></li>
<li ng-if="$ctrl.canViewQuery"><a ng-href="{{$ctrl.widget.getQuery().getUrl(true, $ctrl.widget.visualization.id)}}">View Query</a></li>
<li ng-if="$ctrl.dashboard.canEdit() && $ctrl.hasParameters()">
<li ng-if="$ctrl.dashboard.canEdit() && $ctrl.hasParameters()"><a ng-click="$ctrl.editParameterMappings()">Edit Parameters</a></li>
</li>
<li ng-if="$ctrl.dashboard.canEdit()" class="divider"></li>
<li ng-if="$ctrl.dashboard.canEdit()"><a ng-click="$ctrl.deleteWidget()">Remove from Dashboard</a></li>
</ul>
</div>
<div class="refresh-indicator" ng-if="$ctrl.widget.loading">
<div class="refresh-icon">
<i class="zmdi zmdi-refresh zmdi-hc-spin"></i>
</div>
<rd-timer from="$ctrl.widget.refreshStartedAt"></rd-timer>
</div>
<div class="th-title">
<p>
<query-link query="$ctrl.widget.getQuery()" visualization="$ctrl.widget.visualization"
readonly="!$ctrl.canViewQuery"></query-link>
</p>
<div class="text-muted query--description" ng-bind-html="$ctrl.widget.getQuery().description | markdown"></div>
</div>
</div>
<div class="m-b-10" ng-if="$ctrl.localParametersDefs().length > 0">
<parameters parameters="$ctrl.localParametersDefs()" on-values-change="$ctrl.refresh"></parameters>
</div>
</div>
<div ng-switch-when="failed" class="body-row-auto scrollbox">
<div class="alert alert-danger m-5" ng-show="$ctrl.widget.getQueryResult().getError()">Error running query: <strong>{{$ctrl.widget.getQueryResult().getError()}}</strong></div>
</div>
<div ng-switch-when="done" class="body-row-auto scrollbox">
<visualization-renderer class="t-body"
visualization="$ctrl.widget.visualization"
query-result="$ctrl.widget.getQueryResult()"
filters="$ctrl.filters"
></visualization-renderer>
</div>
<div ng-switch-default class="body-row-auto spinner-container">
<div class="spinner">
<i class="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x"></i>
</div>
</div>
<div class="body-row clearfix tile__bottom-control">
<a class="refresh-button hidden-print btn btn-sm btn-default btn-transparent" ng-click="$ctrl.refresh(1)" ng-if="!$ctrl.public && !!$ctrl.widget.getQueryResult()" data-test="RefreshButton">
<i class="zmdi zmdi-refresh" ng-class="{ 'zmdi-hc-spin': $ctrl.refreshClickButtonId === 1}"></i>
<span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
</a>
<span class="small hidden-print" ng-if="$ctrl.public">
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
</span>
<span class="visible-print">
<i class="zmdi zmdi-time-restore"></i> {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}}
</span>
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.refresh(2)" ng-if="!$ctrl.public">
<i class="zmdi zmdi-refresh" ng-class="{ 'zmdi-hc-spin': $ctrl.refreshClickButtonId === 2}"></i>
</button>
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.expandVisualization()"><i class="zmdi zmdi-fullscreen"></i></button>
</div>
</div>
<div class="tile body-container d-flex justify-content-center align-items-center widget-restricted" ng-if="$ctrl.type=='restricted'" ng-class="$ctrl.type">
<div class="t-body scrollbox">
<div class="text-center">
<h1><span class="zmdi zmdi-lock"></span></h1>
<p class="text-muted">
This widget requires access to a data source you don't have access to.
</p>
</div>
</div>
</div>
<div class="tile body-container widget-text" ng-hide="$ctrl.widget.width === 0" ng-if="$ctrl.type=='textbox'" ng-class="$ctrl.type">
<div class="body-row clearfix t-body">
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
<div class="dropdown-header">
<a class="actions" ng-click="$ctrl.deleteWidget()" title="Remove From Dashboard"><i class="zmdi zmdi-close"></i></a>
</div>
</div>
<div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()"
uib-dropdown dropdown-append-to-body="true">
<div class="dropdown-header">
<a data-toggle="dropdown" uib-dropdown-toggle class="actions p-l-15 p-r-15"><i class="zmdi zmdi-more-vert"></i></a>
</div>
<ul class="dropdown-menu dropdown-menu-right" uib-dropdown-menu style="z-index:1000000">
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.editTextBox()">Edit</a></li>
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
</ul>
</div>
</div>
<div class="body-row-auto scrollbox tiled t-body p-15 markdown" ng-bind-html="$ctrl.widget.text | markdown"></div>
</div>
</div>

View File

@@ -1,135 +0,0 @@
import { filter } from 'lodash';
import { angular2react } from 'angular2react';
import template from './widget.html';
import TextboxDialog from '@/components/dashboards/TextboxDialog';
import widgetDialogTemplate from './widget-dialog.html';
import EditParameterMappingsDialog from '@/components/dashboards/EditParameterMappingsDialog';
import './widget.less';
import './widget-dialog.less';
const WidgetDialog = {
template: widgetDialogTemplate,
bindings: {
resolve: '<',
close: '&',
dismiss: '&',
},
controller() {
this.widget = this.resolve.widget;
},
};
export let DashboardWidget = null; // eslint-disable-line import/no-mutable-exports
function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope, $timeout, Events, currentUser) {
this.canViewQuery = currentUser.hasPermission('view_query');
this.editTextBox = () => {
TextboxDialog.showModal({
dashboard: this.dashboard,
text: this.widget.text,
onConfirm: (text) => {
this.widget.text = text;
return this.widget.save();
},
});
};
this.expandVisualization = () => {
$uibModal.open({
component: 'widgetDialog',
resolve: {
widget: this.widget,
},
size: 'lg',
});
};
this.hasParameters = () => this.widget.query.getParametersDefs().length > 0;
this.editParameterMappings = () => {
EditParameterMappingsDialog.showModal({
dashboard: this.dashboard,
widget: this.widget,
}).result.then((valuesChanged) => {
this.localParameters = null;
// refresh widget if any parameter value has been updated
if (valuesChanged) {
$timeout(() => this.refresh());
}
$scope.$applyAsync();
$rootScope.$broadcast('dashboard.update-parameters');
});
};
this.localParametersDefs = () => {
if (!this.localParameters) {
this.localParameters = filter(
this.widget.getParametersDefs(),
param => !this.widget.isStaticParam(param),
);
}
return this.localParameters;
};
this.deleteWidget = () => {
if (!$window.confirm(`Are you sure you want to remove "${this.widget.getName()}" from the dashboard?`)) {
return;
}
this.widget.delete().then(() => {
if (this.deleted) {
this.deleted({});
}
});
};
Events.record('view', 'widget', this.widget.id);
this.load = (refresh = false) => {
const maxAge = $location.search().maxAge;
return this.widget.load(refresh, maxAge);
};
this.refresh = (buttonId) => {
this.refreshClickButtonId = buttonId;
this.load(true).finally(() => {
this.refreshClickButtonId = undefined;
});
};
if (this.widget.visualization) {
Events.record('view', 'query', this.widget.visualization.query.id, { dashboard: true });
Events.record('view', 'visualization', this.widget.visualization.id, { dashboard: true });
this.type = 'visualization';
this.load();
} else if (this.widget.restricted) {
this.type = 'restricted';
} else {
this.type = 'textbox';
}
}
const DashboardWidgetOptions = {
template,
controller: DashboardWidgetCtrl,
bindings: {
widget: '<',
public: '<',
dashboard: '<',
filters: '<',
deleted: '<',
},
};
export default function init(ngModule) {
ngModule.component('widgetDialog', WidgetDialog);
ngModule.component('dashboardWidget', DashboardWidgetOptions);
ngModule.run(['$injector', ($injector) => {
DashboardWidget = angular2react('dashboardWidget ', DashboardWidgetOptions, $injector);
}]);
}
init.init = true;

View File

@@ -1,121 +0,0 @@
.tile .t-header .th-title a.query-link {
color: rgba(0, 0, 0, 0.5);
}
visualization-name:empty + span {
color: rgba(0, 0, 0, 0.8);
}
visualization-name {
font-size: 15px;
font-weight: 500;
color: rgba(0, 0, 0, 0.8);
&:after {
content: "";
margin-left: 5px;
}
&:empty:after {
content: none;
}
}
.th-title p.hidden-print {
margin-bottom: 0;
}
.widget-wrapper {
.body-container {
display: flex;
flex-direction: column;
align-items: stretch;
.body-row {
flex: 0 1 auto;
}
.body-row-auto {
flex: 1 1 auto;
}
}
.spinner-container {
position: relative;
.spinner {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
}
.dropdown-header {
padding: 0;
.actions {
position: static;
}
}
.t-header.widget {
.dropdown {
margin-top: -15px;
margin-right: -15px;
.actions {
position: static;
}
}
}
.scrollbox:empty {
padding: 0 !important;
font-size: 1px !important;
}
.widget-text {
:first-child {
margin-top: 0;
}
:last-child {
margin-bottom: 0;
}
}
}
// react-grid-layout overrides
.react-grid-item {
// placeholder color
&.react-grid-placeholder {
border-radius: 3px;
background-color: #E0E6EB;
opacity: 0.5;
}
// resize placeholder behind widget, the lib's default is above 🤷‍♂️
&.resizing {
z-index: 3;
}
// auto-height animation
&.cssTransforms:not(.resizing) {
transition-property: transform, height; // added ", height"
}
// resize handle size
& > .react-resizable-handle::after {
width: 11px;
height: 11px;
right: 5px;
bottom: 5px;
}
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Form from 'antd/lib/form';
import Input from 'antd/lib/input';
import InputNumber from 'antd/lib/input-number';
@@ -7,13 +8,17 @@ import Checkbox from 'antd/lib/checkbox';
import Button from 'antd/lib/button';
import Upload from 'antd/lib/upload';
import Icon from 'antd/lib/icon';
import { includes, isFunction } from 'lodash';
import { includes, isFunction, filter, difference, isEmpty, some, isNumber, isBoolean } from 'lodash';
import Select from 'antd/lib/select';
import notification from '@/services/notification';
import Collapse from '@/components/Collapse';
import AceEditorInput from '@/components/AceEditorInput';
import { toHuman } from '@/filters';
import { Field, Action, AntdForm } from '../proptypes';
import helper from './dynamicFormHelper';
import './DynamicForm.less';
const fieldRules = ({ type, required, minLength }) => {
const requiredRule = required;
const minLengthRule = minLength && includes(['text', 'email', 'password'], type);
@@ -51,9 +56,14 @@ class DynamicForm extends React.Component {
constructor(props) {
super(props);
const hasFilledExtraField = some(props.fields, (field) => {
const { extra, initialValue } = field;
return extra && (!isEmpty(initialValue) || isNumber(initialValue) || isBoolean(initialValue) && initialValue);
});
this.state = {
isSubmitting: false,
inProgressActions: [],
showExtraFields: hasFilledExtraField,
};
this.actionCallbacks = this.props.actions.reduce((acc, cur) => ({
@@ -146,9 +156,21 @@ class DynamicForm extends React.Component {
};
return getFieldDecorator(name, decoratorOptions)(
<Select {...props} optionFilterProp="children" loading={loading || false} mode={mode}>
{options && options.map(({ value, title }) => (
<Option key={`${value}`} value={value} disabled={readOnly}>{ title || value }</Option>
<Select
{...props}
optionFilterProp="children"
loading={loading || false}
mode={mode}
getPopupContainer={trigger => trigger.parentNode}
>
{options && options.map(option => (
<Option
key={`${option.value}`}
value={option.value}
disabled={readOnly}
>
{option.name || option.value}
</Option>
))}
</Select>,
);
@@ -157,7 +179,7 @@ class DynamicForm extends React.Component {
renderField(field, props) {
const { getFieldDecorator } = this.props.form;
const { name, type, initialValue } = field;
const fieldLabel = field.title || helper.toHuman(name);
const fieldLabel = field.title || toHuman(name);
const options = {
rules: fieldRules(field),
@@ -183,11 +205,11 @@ class DynamicForm extends React.Component {
return getFieldDecorator(name, options)(<Input {...props} />);
}
renderFields() {
return this.props.fields.map((field) => {
renderFields(fields) {
return fields.map((field) => {
const FormItem = Form.Item;
const { name, title, type, readOnly, autoFocus, contentAfter } = field;
const fieldLabel = title || helper.toHuman(name);
const fieldLabel = title || toHuman(name);
const { feedbackIcons, form } = this.props;
const formItemProps = {
@@ -239,16 +261,35 @@ class DynamicForm extends React.Component {
const submitProps = {
type: 'primary',
htmlType: 'submit',
className: 'w-100',
className: 'w-100 m-t-20',
disabled: this.state.isSubmitting,
loading: this.state.isSubmitting,
};
const { id, hideSubmitButton, saveText } = this.props;
const { id, hideSubmitButton, saveText, fields } = this.props;
const { showExtraFields } = this.state;
const saveButton = !hideSubmitButton;
const extraFields = filter(fields, { extra: true });
const regularFields = difference(fields, extraFields);
return (
<Form id={id} layout="vertical" onSubmit={this.handleSubmit}>
{this.renderFields()}
<Form id={id} className="dynamic-form" layout="vertical" onSubmit={this.handleSubmit}>
{this.renderFields(regularFields)}
{!isEmpty(extraFields) && (
<div className="extra-options">
<Button
type="dashed"
block
className="extra-options-button"
onClick={() => this.setState({ showExtraFields: !showExtraFields })}
>
Additional Settings
<i className={cx('fa m-l-5', { 'fa-caret-up': showExtraFields, 'fa-caret-down': !showExtraFields })} />
</Button>
<Collapse collapsed={!showExtraFields} className="extra-options-content">
{this.renderFields(extraFields)}
</Collapse>
</div>
)}
{saveButton && <Button {...submitProps}>{saveText}</Button>}
{this.renderActions()}
</Form>

View File

@@ -0,0 +1,29 @@
@import '~@/assets/less/ant';
.dynamic-form{
.extra-options {
margin: 25px 0 10px;
}
.extra-options-button {
&, &:focus, &:hover {
height: 40px;
font-weight: 500;
background-color: @btn-danger-bg;
border-color: @btn-danger-border;
color: @btn-default-color;
}
&:focus, &:hover {
background-color: fade(@btn-danger-bg, 15%);
}
}
.extra-options-content {
margin-top: 15px;
.ant-form-item:last-of-type {
margin-bottom: 0 !important;
}
}
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { each, includes, isUndefined } from 'lodash';
import { each, includes, isUndefined, isEmpty, map } from 'lodash';
function orderedInputs(properties, order, targetOptions) {
const inputs = new Array(order.length);
@@ -11,9 +11,15 @@ function orderedInputs(properties, order, targetOptions) {
type: properties[key].type,
placeholder: properties[key].default && properties[key].default.toString(),
required: properties[key].required,
extra: properties[key].extra,
initialValue: targetOptions[key],
};
if (input.type === 'select') {
input.placeholder = 'Select an option';
input.options = properties[key].options;
}
if (position > -1) {
inputs[position] = input;
} else {
@@ -41,27 +47,46 @@ function normalizeSchema(configurationSchema) {
prop.type = 'text';
}
if (!isEmpty(prop.enum)) {
prop.type = 'select';
prop.options = map(prop.enum, value => ({ value, name: value }));
}
if (!isEmpty(prop.extendedEnum)) {
prop.type = 'select';
prop.options = prop.extendedEnum;
}
prop.required = includes(configurationSchema.required, name);
prop.extra = includes(configurationSchema.extra_options, name);
});
configurationSchema.order = configurationSchema.order || [];
}
function setDefaultValueForCheckboxes(configurationSchema, options = {}) {
if (Object.keys(options).length === 0) {
const properties = configurationSchema.properties;
Object.keys(properties).forEach((property) => {
if (!isUndefined(properties[property].default) && properties[property].type === 'checkbox') {
options[property] = properties[property].default;
}
});
}
function setDefaultValueToFields(configurationSchema, options = {}) {
const properties = configurationSchema.properties;
Object.keys(properties).forEach((key) => {
const property = properties[key];
// set default value for checkboxes
if (!isUndefined(property.default) && property.type === 'checkbox') {
options[key] = property.default;
}
// set default or first value when value has predefined options
if (property.type === 'select') {
const optionValues = map(property.options, option => option.value);
options[key] = includes(optionValues, property.default) ? property.default : optionValues[0];
}
});
}
function getFields(type = {}, target = { options: {} }) {
const configurationSchema = type.configuration_schema;
normalizeSchema(configurationSchema);
setDefaultValueForCheckboxes(configurationSchema, target.options);
const hasTargetObject = Object.keys(target.options).length > 0;
if (!hasTargetObject) {
setDefaultValueToFields(configurationSchema, target.options);
}
const isNewTarget = !target.id;
const inputs = [
@@ -90,10 +115,6 @@ function updateTargetWithValues(target, values) {
});
}
function toHuman(text) {
return text.replace(/_/g, ' ').replace(/(?:^|\s)\S/g, a => a.toUpperCase());
}
function getBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -106,6 +127,5 @@ function getBase64(file) {
export default {
getFields,
updateTargetWithValues,
toHuman,
getBase64,
};

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import moment from 'moment';
import { includes } from 'lodash';
import { isDynamicDate, getDynamicDate } from '@/services/query';
import { isDynamicDate, getDynamicDateFromString } from '@/services/parameters/DateParameter';
import DateInput from '@/components/DateInput';
import DateTimeInput from '@/components/DateTimeInput';
import DynamicButton from '@/components/dynamic-parameters/DynamicButton';
@@ -12,11 +12,11 @@ import './DynamicParameters.less';
const DYNAMIC_DATE_OPTIONS = [
{ name: 'Today/Now',
value: 'd_now',
label: () => getDynamicDate('d_now').value().format('MMM D') },
value: getDynamicDateFromString('d_now'),
label: () => getDynamicDateFromString('d_now').value().format('MMM D') },
{ name: 'Yesterday',
value: 'd_yesterday',
label: () => getDynamicDate('d_yesterday').value().format('MMM D') },
value: getDynamicDateFromString('d_yesterday'),
label: () => getDynamicDateFromString('d_yesterday').value().format('MMM D') },
];
class DateParameter extends React.Component {
@@ -44,7 +44,7 @@ class DateParameter extends React.Component {
onDynamicValueSelect = (dynamicValue) => {
const { onSelect, parameter } = this.props;
if (dynamicValue === 'static') {
const parameterValue = parameter.getValue();
const parameterValue = parameter.getExecutionValue();
if (parameterValue) {
onSelect(moment(parameterValue));
} else {
@@ -77,7 +77,7 @@ class DateParameter extends React.Component {
}
if (hasDynamicValue) {
const dynamicDate = getDynamicDate(value);
const dynamicDate = value;
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
additionalAttributes.value = null;
}

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import moment from 'moment';
import { includes, isArray, isObject } from 'lodash';
import { isDynamicDateRange, getDynamicDateRange } from '@/services/query';
import { isDynamicDateRange, getDynamicDateRangeFromString } from '@/services/parameters/DateRangeParameter';
import DateRangeInput from '@/components/DateRangeInput';
import DateTimeRangeInput from '@/components/DateTimeRangeInput';
import DynamicButton from '@/components/dynamic-parameters/DynamicButton';
@@ -12,29 +12,37 @@ import './DynamicParameters.less';
const DYNAMIC_DATE_OPTIONS = [
{ name: 'This week',
value: 'd_this_week',
label: () => getDynamicDateRange('d_this_week').value()[0].format('MMM D') + ' - ' +
getDynamicDateRange('d_this_week').value()[1].format('MMM D') },
{ name: 'This month', value: 'd_this_month', label: () => getDynamicDateRange('d_this_month').value()[0].format('MMMM') },
{ name: 'This year', value: 'd_this_year', label: () => getDynamicDateRange('d_this_year').value()[0].format('YYYY') },
value: getDynamicDateRangeFromString('d_this_week'),
label: () => getDynamicDateRangeFromString('d_this_week').value()[0].format('MMM D') + ' - ' +
getDynamicDateRangeFromString('d_this_week').value()[1].format('MMM D') },
{ name: 'This month',
value: getDynamicDateRangeFromString('d_this_month'),
label: () => getDynamicDateRangeFromString('d_this_month').value()[0].format('MMMM') },
{ name: 'This year',
value: getDynamicDateRangeFromString('d_this_year'),
label: () => getDynamicDateRangeFromString('d_this_year').value()[0].format('YYYY') },
{ name: 'Last week',
value: 'd_last_week',
label: () => getDynamicDateRange('d_last_week').value()[0].format('MMM D') + ' - ' +
getDynamicDateRange('d_last_week').value()[1].format('MMM D') },
{ name: 'Last month', value: 'd_last_month', label: () => getDynamicDateRange('d_last_month').value()[0].format('MMMM') },
{ name: 'Last year', value: 'd_last_year', label: () => getDynamicDateRange('d_last_year').value()[0].format('YYYY') },
value: getDynamicDateRangeFromString('d_last_week'),
label: () => getDynamicDateRangeFromString('d_last_week').value()[0].format('MMM D') + ' - ' +
getDynamicDateRangeFromString('d_last_week').value()[1].format('MMM D') },
{ name: 'Last month',
value: getDynamicDateRangeFromString('d_last_month'),
label: () => getDynamicDateRangeFromString('d_last_month').value()[0].format('MMMM') },
{ name: 'Last year',
value: getDynamicDateRangeFromString('d_last_year'),
label: () => getDynamicDateRangeFromString('d_last_year').value()[0].format('YYYY') },
{ name: 'Last 7 days',
value: 'd_last_7_days',
label: () => getDynamicDateRange('d_last_7_days').value()[0].format('MMM D') + ' - Today' },
value: getDynamicDateRangeFromString('d_last_7_days'),
label: () => getDynamicDateRangeFromString('d_last_7_days').value()[0].format('MMM D') + ' - Today' },
];
const DYNAMIC_DATETIME_OPTIONS = [
{ name: 'Today',
value: 'd_today',
label: () => getDynamicDateRange('d_today').value()[0].format('MMM D') },
value: getDynamicDateRangeFromString('d_today'),
label: () => getDynamicDateRangeFromString('d_today').value()[0].format('MMM D') },
{ name: 'Yesterday',
value: 'd_yesterday',
label: () => getDynamicDateRange('d_yesterday').value()[0].format('MMM D') },
value: getDynamicDateRangeFromString('d_yesterday'),
label: () => getDynamicDateRangeFromString('d_yesterday').value()[0].format('MMM D') },
...DYNAMIC_DATE_OPTIONS,
];
@@ -73,7 +81,7 @@ class DateRangeParameter extends React.Component {
onDynamicValueSelect = (dynamicValue) => {
const { onSelect, parameter } = this.props;
if (dynamicValue === 'static') {
const parameterValue = parameter.getValue();
const parameterValue = parameter.getExecutionValue();
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
} else {
@@ -107,8 +115,7 @@ class DateRangeParameter extends React.Component {
}
if (hasDynamicValue) {
const dynamicDateRange = getDynamicDateRange(value);
additionalAttributes.placeholder = [dynamicDateRange && dynamicDateRange.name];
additionalAttributes.placeholder = [value && value.name];
additionalAttributes.value = null;
}

View File

@@ -5,6 +5,8 @@ import Dropdown from 'antd/lib/dropdown';
import Icon from 'antd/lib/icon';
import Menu from 'antd/lib/menu';
import Typography from 'antd/lib/typography';
import { DynamicDateType } from '@/services/parameters/DateParameter';
import { DynamicDateRangeType } from '@/services/parameters/DateRangeParameter';
import './DynamicButton.less';
@@ -62,7 +64,7 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
DynamicButton.propTypes = {
options: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
selectedDynamicValue: PropTypes.string,
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
onSelect: PropTypes.func,
enabled: PropTypes.bool,
};

View File

@@ -40,7 +40,7 @@ Step.defaultProps = {
export function EmptyState({
icon,
title,
header,
description,
illustration,
helpLink,
@@ -75,7 +75,7 @@ export function EmptyState({
return (
<div className="empty-state bg-white tiled">
<div className="empty-state__summary">
{title && <h4>{title}</h4>}
{header && <h4>{header}</h4>}
<h2>
<i className={icon} />
</h2>
@@ -148,7 +148,7 @@ export function EmptyState({
EmptyState.propTypes = {
icon: PropTypes.string,
title: PropTypes.string,
header: PropTypes.string,
description: PropTypes.string.isRequired,
illustration: PropTypes.string.isRequired,
helpLink: PropTypes.string.isRequired,
@@ -161,7 +161,7 @@ EmptyState.propTypes = {
EmptyState.defaultProps = {
icon: null,
title: null,
header: null,
onboardingMode: false,
showAlertStep: false,

Some files were not shown because too many files have changed in this diff Show More