Compare commits

...

374 Commits

Author SHA1 Message Date
snickerjp
43ee21ac20 Feature/catch notsupported exception (#7573)
* Handle NotSupported exception in refresh_schema

- Add NotSupported exception handling to refresh_schema()
- Log unsupported datasources at DEBUG level
- Avoid error metrics for datasources without schema support

* Add test for NotSupported exception handling

- Test that NotSupported exceptions are caught and logged at DEBUG level
- Verify no warning logs are generated for unsupported datasources

* Fix import order (ruff)

* Remove test for NotSupported exception handling

As suggested by @yoshiokatsuneo, testing logging details for 3 lines of code
is excessive and may hurt maintainability. The existing tests already ensure
the functionality works correctly.
2025-12-19 11:15:45 +09:00
Eric Radman
262d46f465 Multi-org: format base path, not including protocol (#7260)
Remove hard-coded 'https://' when MULTI_ORG is enabled
2025-12-17 19:34:30 -05:00
gaojingyu
bc68b1c38b fix(destinations): Handle unicode characters in webhook notifications (#7586)
* fix(destinations): Handle unicode characters in webhook notifications

Previously, webhook notifications would fail if they contained unicode characters in the alert data. This was because the JSON payload was not UTF-8 encoded before being sent.

This commit fixes the issue by explicitly encoding the JSON data to UTF-8 and adds a test to verify the fix.

* move test function to new file

---------

Co-authored-by: gaojingyu <gaojingyu>
2025-12-16 00:02:30 +09:00
Vladislav Denisov
4353a82c7a Persist updated values and apply saved dashboard parameters (#7570)
Add support for saving dashboard parameters after clicking the Apply button. Parameters are applied in the following order: URL, dashboard parameters, query parameters.

Persist the queued values only when “Done Editing” is clicked, keeping Query and Dashboard editors aligned.
2025-12-12 11:59:05 +09:00
Nicolas Ferrandini
761eb0b68b Add ibm-db package to enable DB2 as datasource: (#7581)
* Add ibm-db package to enable DB2 as datasource:

* Review poetry format

* Added condition on platform for ibm-db, as support is restricted

---------

Co-authored-by: nicof38 <nicolas@FB-L-230557.soitec.net>
Co-authored-by: Tsuneo Yoshioka <yoshiokatsuneo@gmail.com>
2025-12-09 14:25:05 +00:00
github-actions[bot]
9743820efe Snapshot: 25.12.0-dev 2025-12-01 00:46:06 +00:00
Eric Radman
9d49e0457f PostgreSQL: allow connection parameters to be specified (#7579)
As documented in
https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS

Multiple parameters are separated by a space.
2025-11-26 09:32:11 -05:00
Eric Radman
b5781a8ebe Add lineShape option for Line and Area charts (#7582)
Linear
Spline
Horizontal-Vertical
Vertical-Horizontal
2025-11-25 11:43:15 -05:00
Tsuneo Yoshioka
b6f4159be9 Add "Last 10 years" option for dynamic date range (#7422) 2025-11-21 10:23:58 +09:00
Sarvesh Vazarkar
d5fbf547cf pg: fix has_privileges function to quote schema and table names (#7574) 2025-11-20 10:14:03 -05:00
github-actions[bot]
772b160a79 Snapshot: 25.11.0-dev 2025-11-01 00:39:20 +00:00
Tsuneo Yoshioka
bac2160e2a Advanced query search syntax for multi byte search (#7546)
* Advanced query search syntax for multi byte search

* Advanced search for my queries

* Add advanced query seearch tooltip

* Revert "Add advanced query seearch tooltip"

This reverts commit 43148ba6ac.
2025-10-15 16:12:00 +00:00
Tsuneo Yoshioka
c5aa5da6a2 Update queries.latest_query_data on save (#7560)
* Update queries.latest_query_data on save

* Add wait on test as loading query and query results may re-render DOM and that makes test fraky

* Fix styling report by prettier
2025-10-14 22:59:36 +09:00
Eric Radman
9503cc9fb8 Correct custom chart help text: use newPlot() (#7557) 2025-10-08 07:44:29 -04:00
Tsuneo Yoshioka
b353057f9a Update ace-builds/react-ace to the latest versions (#7532) 2025-10-06 11:14:37 -04:00
Eric Radman
8747d02bbe Use standard PostgreSQL image and drop clean-all target (#7555) 2025-10-06 09:48:49 -04:00
Kamil Frydel
5b463b0d83 Make details visualization configurable (#7535)
- Added possibility to select visible columns and reordering
- Added formatting options as in Table visualization
- Set default alignment to left
2025-10-06 08:10:33 -04:00
Tsuneo Yoshioka
ea589ad477 Query Serach: avoid concurrent search API request (#7551) 2025-10-02 14:52:57 +00:00
Tsuneo Yoshioka
617124850b SchemaBrowser: on column comment tooltip, show newlines correctly (#7552) 2025-10-02 23:22:01 +09:00
Zafer Balkan
1cc200843c Add duckdb support (#7548) 2025-10-01 23:27:13 +09:00
github-actions[bot]
e0410e2ffe Snapshot: 25.10.0-dev 2025-10-01 00:39:34 +00:00
Tsuneo Yoshioka
7e39b3668d MySQL: add column type, comment, and table comment (#7544) 2025-09-30 19:20:34 +00:00
Tsuneo Yoshioka
92f15a3ccb BigQuery: Add table description on Schema Browser (#7543) 2025-10-01 03:52:36 +09:00
Tsuneo Yoshioka
9a1d33381c BigQuery: support multiple locations (#7540) 2025-09-25 22:01:17 +09:00
Tsuneo Yoshioka
56c06adc24 BigQuery: Remove "Job ID" metadata on annotaton to avoid cache misses (#7541) 2025-09-24 23:44:40 +09:00
Tsuneo Yoshioka
5e8915afe5 BigQuery: show column description(comment) on Schema Browser (#7538)
* BigQuery: add column description to Schema Browser

* fix restyled prettier error

* Remove column-description
2025-09-23 23:54:01 +09:00
Tsuneo Yoshioka
b8ebf49436 Make favorite queries/dashboard order by starred at(favorited at) (#7351)
* Make favorite queries/dashboard order by starred at(favorited at)

* fix styling for restyled error
2025-09-16 16:24:03 +09:00
Tsuneo Yoshioka
59951eda3d Fix/too many history replace state (#7530)
* Fix too many history.replaceState() error on Safari

* fix restyled error by running prettier for client/app/services/location.js
2025-09-12 03:41:04 +09:00
Artem Safiiulin
777153e7a0 Update jql.py (jira datasource) to use jira api v3 updated. (#7527)
* Update jql.py (jira datasource) to use jira api v3 updated.

* fix spaces in blank lines

* Add condition for empty "fields"

---------

Co-authored-by: Artem Safiiulin <asafiiulin@cloudlinux.com>
Co-authored-by: Tsuneo Yoshioka <yoshiokatsuneo@gmail.com>
2025-09-10 23:43:05 +09:00
Tsuneo Yoshioka
47b1309f13 Add range slider to the chart (#7525) 2025-09-09 19:22:53 +00:00
Tsuneo Yoshioka
120250152f Add "Missing and NULL values" option to scatter chart (#7523) 2025-09-08 23:54:11 +00:00
Tsuneo Yoshioka
ac81f0b223 keep ordering on search (#7520) 2025-09-08 17:22:35 +00:00
Tsuneo Yoshioka
7838058953 fix: webpack missing source-map warning for @plotly/msgbox-gl (#7522) 2025-09-08 14:18:10 +00:00
Eric Radman
f95156e924 Rely on information_schema.columns for views and foreign tables (#7521)
This prevents duplicate entries in the schema list.  Materialized views are the
only table-like object not found information_schema. Also ensure that the schema
and table found in information_schema is accessible by the current user.
2025-09-08 09:41:28 -04:00
Tsuneo Yoshioka
74de676bdf Allow HTTP request line more than 4096 bytes (#7506) 2025-09-04 18:05:05 +00:00
Tsuneo Yoshioka
2762f1fc85 Fix: null is not shown for text with "Allow HTML content" (#7519) 2025-09-03 10:55:26 -04:00
github-actions[bot]
438efd0826 Snapshot: 25.09.0-dev 2025-09-01 00:43:37 +00:00
Tsuneo Yoshioka
e586ab708b Fix stacking bar chart (#7516) 2025-08-30 03:31:47 +09:00
Tsuneo Yoshioka
24ca5135aa Update plotly.js to 3.1.0 (#7514) 2025-08-28 17:06:54 +09:00
Eric Radman
fae354fcce Update Poetry to 2.1.4 (#7509)
* Relocate [tool.poetry] to [project] section
* Add dependencies section to [project]
* Format authors and maintainers as objects
2025-08-26 08:08:19 -04:00
Tsuneo Yoshioka
4ae372f022 Update from webpack4 to webpack5 (#7507) 2025-08-25 20:50:18 +00:00
Tsuneo Yoshioka
0b5907f12b Fix css height for mobile safari not to overlap URL bar (#7334) 2025-08-22 18:42:36 +00:00
Adrian Oesch
00a97d9266 Add private_key auth method to snowflake query runner (#7371)
* add private_key auth method

* fix casing

* fix private_key parsing

* use params and add optional pwd

* use private_key_b64

* add file option

* remove __contains__

* fix pem pwd

* fix lint issues

* fix black

---------

Co-authored-by: Tsuneo Yoshioka <yoshiokatsuneo@gmail.com>
2025-08-08 17:38:22 +00:00
github-actions[bot]
35afe880a1 Snapshot: 25.08.0-dev 2025-08-01 00:45:49 +00:00
Tsuneo Yoshioka
a6298f2753 MongoDB: fix for empty username/password (#7487) 2025-07-31 23:39:39 +09:00
Zach Liu
e69283f488 clickhouse: display data types (#7490) 2025-07-31 13:08:40 +00:00
Eric Radman
09ed3c4b81 Clickhouse: do not display INFORMATION_SCHEMA tables (#7489)
As with other query runners, do not show system tables in the schema list.
2025-07-31 08:07:40 -04:00
Eric Radman
f5e2a4c0fc Sort Dashboard and Query tags by name (#7484) 2025-07-23 11:34:26 -04:00
Lee2532
4e200b4a08 bigquery load schema diff locations ignore (#7289)
* diff locations ignore

* add logging message

* Processing Location is not specified
2025-07-22 15:45:37 +00:00
Костятнин Дементьєв
5ae1f70d9e Add support for Google OAuth Scheme Override (#7178)
* Added support for Google Oauth Scheme Override (through environment variable)

* Refactoring

* Refactoring

* Applied formatting

* Refactoring

* Refactoring

* Updated comment for `GOOGLE_OAUTH_SCHEME_OVERRIDE` variable

* Updated comment for `GOOGLE_OAUTH_SCHEME_OVERRIDE` variable

* Removed duplication of url_for function

---------

Co-authored-by: kostiantyn-dementiev-op <kostiantyn.dementiev@observepoint.com>
2025-07-21 00:05:43 +09:00
Eric Radman
3f781d262b Push by tag name for Docker repository "redash" (#7321) 2025-07-17 14:50:13 -04:00
Tsuneo Yoshioka
a34c1591e3 Upgrade prettier version to the same version that CI is using (#7367) 2025-07-18 00:04:55 +09:00
Eric Radman
9f76fda18c Use 12-column layout for dashboard grid (#7396)
* Use 12-column layout for dashboard grid

Set minSizeX, minSizeY for widgets to 2 since a value of 1 breaks all
assumptions of the UI layout.

Migration provide transition from 6 to 12 columns for all widgets.

* Restyled by prettier
2025-07-16 01:24:21 +00:00
Tsuneo Yoshioka
d8ae679937 Make NULL values visible (#7439)
* Make NULL value visible
* Make the representation of NULL value configurable
* use display-as-null css class for null-value styling
2025-07-16 00:48:36 +00:00
Elliot Maincourt
f3b0b60abd feat(flask): make refresh cookie name configurable (#7473) 2025-07-09 12:09:24 -04:00
Kamil Frydel
df8be91a07 Add migration to set default alert selector (#7475)
In commits fc1e1f7 and e44fcdb a new Selector option was added to
alerts, which may be "first", "min" or "max".  This migration sets the
default to "first" for existing alerts.
2025-07-09 13:20:12 +00:00
github-actions[bot]
c9ddd2a7d6 Snapshot: 25.07.0-dev 2025-07-01 00:43:15 +00:00
github-actions[bot]
6b1e910126 Snapshot: 25.06.0-dev 2025-06-01 00:45:45 +00:00
Tsuneo Yoshioka
14550a9a6c Fix: saving empty query with auto limit crashes (#7430)
Co-authored-by: Eric Radman <eradman@starfishstorage.com>
2025-05-20 14:26:17 +00:00
Emmanuel Ferdman
b80c5f6a7c Update assertion method in JSON dumps test (#7424)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
Co-authored-by: snickerjp <snickerjp@gmail.com>
2025-05-18 12:03:41 -07:00
Tsuneo Yoshioka
e46d44f208 include Plotly.js localization (#7323) 2025-05-16 19:17:32 -04:00
Tsuneo Yoshioka
a1a4bc9d3e TypeScript sourcemap for viz-lib (#7336) 2025-05-12 18:08:33 -04:00
Tsuneo Yoshioka
0900178d24 Change query processing wait time to make response quick (#7320)
* Change query processing wait time to make more response quick

* Fix styling errors reportered by restyled
2025-05-07 01:22:35 +00:00
Gleb Lesnikov
5d31429ca8 Update Azure Data Explorer query runner to latest version (#7411)
* Update Azure Data Explorer query runner to latest version

* Fix black issue

* downgrade azure-kusto-data to 4.6.3

* Freeze numpy to 1.24.4 because of 2.0.0 incompatibility

* Fix failing test

* Reformat test
2025-05-05 06:53:07 +00:00
Eric Radman
2f35ceb803 Push image using DOCKER_REPOSITORY (#7428)
Preview images work for personal repositories, but we missed another variable
when publishing official images:

  #34 [auth] arikfr/redash:pull,push token for registry-1.docker.io
  #34 DONE 0.0s
  #33 exporting to image
  #33 pushing layers 15.5s done
  #33 pushing manifest for docker.io/arikfr/redash
  #33 pushing manifest for docker.io/arikfr/redash 1.6s done
  #33 ...
  #35 [auth] arikfr/preview:pull,push token for registry-1.docker.io
  #35 DONE 0.0s
2025-05-04 23:18:53 -07:00
Lucas Fernando Cardoso Nunes
8e6c02ecde ci: snapshot only on default branch (#7355) 2025-05-01 13:15:57 +00:00
github-actions[bot]
231fd36d46 Snapshot: 25.05.0-dev 2025-05-01 00:39:58 +00:00
Tsuneo Yoshioka
0b6a53a079 Add translate="no" to html tag to prevent redash from translating and crashing (#7421) 2025-04-29 12:36:26 -04:00
Tsuneo Yoshioka
6167edf97c Change BigQuery super class from BaseQueryRunner to BaseSQLQueryRunner (#7378) 2025-04-16 16:28:17 +09:00
Tsuneo Yoshioka
4ed0ad3c9c BigQuery: Avoid too long(10 seconds) interval for bigquery api to get results (#7342) 2025-04-14 11:40:24 +00:00
Eric Radman
2375f0b05f Partiallly Revert "Remove workaround from check_csrf() (#6919)" (#7327)
This workaround was missing 'if view is not None ' as found in
https://github.com/pallets-eco/flask-wtf/pull/419/files

Tested with MULTI_ORG enabled.
2025-04-10 22:25:49 +00:00
Eric Radman
eced377ae4 Require vars.DOCKER_REPOSITORY to publish image (#7400)
To allow user arikfr to publish images to redash/redash and redash/preview.
Only use vars.DOCKER_USER and secrets.DOCKER_PASSWORD for authorization.
2025-04-03 15:27:11 -04:00
Tsuneo Yoshioka
84262fe143 Fix table item list ordering (#7366)
Fix query list item list sorting

- descending order, no triangle mark
- ascending order, up triangle mark(▲)
- descending order, down triangle mark(▼)
- ascending order, no triangle mark
- descending order, up triangle mark(▲)
- ascending order, down triangle mark(▼)
- descending order, no triangle mark

"sorting order" have 2-click cycle, but "triangle mark" have 3-click cycle.
2025-04-03 16:51:20 +00:00
github-actions[bot]
612eb8c630 Snapshot: 25.04.0-dev 2025-04-01 00:39:21 +00:00
dependabot[bot]
866fb48afb Bump tar-fs from 2.1.1 to 2.1.2 (#7385) 2025-03-29 04:56:15 +00:00
Tsuneo Yoshioka
353776e8e1 Fix to make "show data labels" on bar chart works (#7363) 2025-03-17 11:43:02 -04:00
Tsuneo Yoshioka
594e2f24ef Upgrade plotly.js to version 2 to fix the UI crashing issue (#7359)
* Upgrade plotly.js to version 2

* Fix styling error reported by styled
2025-03-05 14:30:28 +00:00
github-actions[bot]
3275a9e459 Snapshot: 25.03.0-dev 2025-03-01 00:35:44 +00:00
Shunki
3bad8c8e8c TiDB: Exclude INFORMATION_SCHEMA (#7352)
Co-authored-by: snickerjp <snickerjp@gmail.com>
2025-02-28 11:09:46 +09:00
Tsuneo Yoshioka
d0af4499d6 Sanitize NaN, Infinite, -Infinite causing error when saving as PostgreSQL JSON #7339 (2nd try) (#7348)
* Sanitize NaN, Infinite, -Infinite causing error when saving as PostgreSQL JSON #7339 (2nd try)

* Move json nsanitaize to on the top of json_dumps

* Fix comment
2025-02-27 01:40:43 -08:00
Ran Benita
4357ea56ae Fix UnboundLocalError when checking alerts for query (#7346)
This fixes the following exception:

```
UnboundLocalError: local variable 'value_is_number' referenced before assignment
  File "rq/worker.py", line 1431, in perform_job
    rv = job.perform()
  File "rq/job.py", line 1280, in perform
    self._result = self._execute()
  File "rq/job.py", line 1317, in _execute
    result = self.func(*self.args, **self.kwargs)
  File "redash/tasks/alerts.py", line 36, in check_alerts_for_query
    new_state = alert.evaluate()
  File "redash/models/__init__.py", line 1002, in evaluate
    new_state = next_state(op, value, threshold)
  File "redash/models/__init__.py", line 928, in next_state
    elif not value_is_number and op not in [OPERATORS.get("!="), OPERATORS.get("=="), OPERATORS.get("equals")]:
```
2025-02-25 09:15:20 -05:00
Tsuneo Yoshioka
5df5ca87a2 add NULLS LAST option for Query order (#7341) 2025-02-25 10:58:48 +08:00
Tsuneo Yoshioka
8387fe6fcb Fix the issue that chart(scatter, line, bubble...) having same x-value have wrong y-value (#7330) 2025-02-18 20:04:12 +00:00
snickerjp
e95de2ee4c Update oracledb package to version 2.5.1 and adjust Python version compatibility (#7316) 2025-02-18 23:00:09 +09:00
Lee2532
71902e5933 FIX : redash docker image TAG (#7280)
Co-authored-by: snickerjp <snickerjp@gmail.com>
2025-02-15 01:38:23 +09:00
Tsuneo Yoshioka
53eab14cef Make autocomplete always available (#7326) 2025-02-13 15:25:39 -05:00
Eric Radman
925bb91d8e Use absolute path for image resources (#7322)
When MULTI_ORG is enabled, 'static/' resolves to '<org>/static/'
2025-02-12 08:37:40 -05:00
Tsuneo Yoshioka
ec2ca6f986 BigQuery: show column type on Schema Browser (#7257) 2025-02-05 18:25:39 +00:00
Matt Nelson
96ea0194e8 Fix errors in webex alert destination. Add formatting support for QUERY_RESULT_TABLE. (#7296)
* prevent text values in payload being detected as 'set' on send.
Webex send ERROR:: Object of type set is not JSON serializable

Signed-off-by: Matt Nelson <metheos@gmail.com>

* add support for formatted QUERY_RESULT_TABLE in webex card

Signed-off-by: Matt Nelson <metheos@gmail.com>

* don't try to send to blank destinations

Signed-off-by: Matt Nelson <metheos@gmail.com>

* fix handling of the encoded QUERY_RESULTS_TABLE text

Signed-off-by: Matt Nelson <metheos@gmail.com>

* re-sort imports for ruff

Signed-off-by: Matt Nelson <metheos@gmail.com>

* change formatter to black

Signed-off-by: Matt Nelson <metheos@gmail.com>

* Add additional tests for Webex notification handling

ensure blank entries are handled for room IDs and person emails.
ensure that the API is not called when no valid destinations are provided.
ensure proper attachment formatting for alerts containing 2D arrays.

Signed-off-by: Matt Nelson <metheos@gmail.com>

* Add test for Webex notification with 1D array handling

This commit introduces a new test case to verify that the Webex
notification function correctly handles a 1D array input in the alert body.
The test ensures that the expected payload is constructed properly and that
the requests.post method is called with the correct parameters.

Signed-off-by: Matt Nelson <metheos@gmail.com>

---------

Signed-off-by: Matt Nelson <metheos@gmail.com>
2025-02-04 11:05:13 +00:00
github-actions[bot]
2776992101 Snapshot: 25.02.0-dev 2025-02-01 00:33:52 +00:00
Arik Fraimovich
85f001982e GitHub Actions Workflow updates (#7298)
* Split out secrets requiring workflows

* Update target

* Update Cypress run command
2025-01-31 10:20:04 +02:00
Motoi Washida
d03a2c4096 Fix error in rehash DB migration with Elasticsearch queries (#7292)
Fixes #7272
2025-01-22 21:19:59 -05:00
SeongTae Jeong
8c5890482a Use ARM64 runners instead of virtualization for ARM64 image builds (#7291) 2025-01-19 16:00:19 +10:00
Ezra Odio
10ce280a96 Default to not allow HTML content in tables (#7064)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
2025-01-15 10:09:24 -05:00
dependabot[bot]
0dd7ac3d2e Bump virtualenv from 20.25.0 to 20.26.6 (#7276) 2025-01-14 01:45:58 +00:00
github-actions[bot]
4ee53a9445 Snapshot: 25.01.0-dev 2025-01-01 00:35:12 +00:00
SeongTae Jeong
c08292d90e Use Codecov token (#7265) 2024-12-30 21:06:09 +00:00
SeongTae Jeong
3142131cdd Bump actions/upload-artifact from v3 to v4 (#7266)
Related: https://github.blog/changelog/2024-04-16-deprecation-notice-v3-of-the-artifact-actions/
2024-12-30 15:31:03 -05:00
Daisuke Taniwaki
530c1a0734 Support result reuse in Athena data sources (#7202)
* Support result reuse

* Update pyathena to 2.25.2

* Separate options

* Regenerate the Poetry lock file

---------

Co-authored-by: SeongTae Jeong <seongtaejg@gmail.com>
2024-12-28 05:50:16 +09:00
dependabot[bot]
52dc1769a1 Bump jinja2 from 3.1.4 to 3.1.5 (#7262)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 13:55:21 +10:00
Eric Radman
b9583c0b48 Create workflow trigger for publishing release image (#7259)
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-12-27 12:19:32 +10:00
Arik Fraimovich
89d7f54e90 Handle the case when query runner configuration is an empty dict. (#7258) 2024-12-24 09:42:39 -05:00
Tsuneo Yoshioka
d884da2b0b BigQuery: add date, datetime type mapping (#7252) 2024-12-18 14:24:45 +02:00
dependabot[bot]
f7d485082c Bump nanoid from 3.3.6 to 3.3.8 (#7249)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.6 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-13 17:57:05 +09:00
Eric Radman
130ab1fe1a Update to paramiko-3.4.1 (#7240)
Solves the deprecation warning for TripleDES
Related: https://github.com/paramiko/paramiko/issues/2419
2024-12-07 11:23:45 +09:00
github-actions[bot]
2ff83679fe Snapshot: 24.12.0-dev 2024-12-01 00:40:40 +00:00
Eric Radman
de49b73855 Replace ptvsd with debugpy to match modern VS Code (#7234) 2024-11-27 08:19:05 +10:00
thiagogds
c12e68f5d1 Only evaluate the next state if there's a value (#7222)
I've experience this on my Redash in production. I'm not sure what can cause the value to exist, but be None. I guess it depends on the SQL query.

I followed the same idea of returning a self.UNKNOWN_STATE for cases that we can't know what's happening.
2024-11-26 12:57:34 -05:00
Eric Radman
baa9bbd505 Use head.sha for restyled checkout (#7227) 2024-11-22 10:34:16 +10:00
Arik Fraimovich
349cd5d031 Bring back version check & beacon reporting (#7211)
Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-11-06 01:21:03 +00:00
github-actions[bot]
49277d27f8 Snapshot: 24.11.0-dev 2024-11-01 00:35:04 +00:00
Yeger
2aae5705c9 don't crash when there is no data (#7208)
* don't crash when there is no data

* Add test
2024-10-31 08:49:57 +00:00
dependabot[bot]
38d0579660 Bump elliptic from 6.5.7 to 6.6.0 (#7214)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-31 02:41:33 +00:00
Ezra Odio
673ba769c7 Fix issue with scheduled queries (#7111)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2024-10-29 10:36:05 +00:00
Eric Radman
b922730482 Docker build: use heredoc for multi-line actions (#7210) 2024-10-29 10:23:15 +10:00
Arik Fraimovich
ba973eb1fe Fixes #6767: correctly rehash queries in a migration (#7184) 2024-10-25 01:00:29 +00:00
dependabot[bot]
d8dde6c544 Bump cryptography from 42.0.8 to 43.0.1 (#7205)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-25 00:27:23 +00:00
dependabot[bot]
d359a716a7 Bump http-proxy-middleware from 2.0.6 to 2.0.7 (#7204)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-24 23:56:09 +00:00
dependabot[bot]
ba4293912b Bump snowflake-connector-python from 3.12.0 to 3.12.3 (#7203)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-24 23:23:28 +00:00
thiagogds
ee359120ee Use correct redis connection (#7077) 2024-10-24 17:54:09 +10:00
thiagogds
04a25f4327 Fix RQ wrongly moving jobs to FailedJobRegistry (#7186)
Something changed in python-rq and the old code was behaving in a way that if a job ran for longer than 2 min it would be automatically set as failed, but it would continue running.

This causes a problem in the UI because it is as if the job stopped, but it actually didn't
2024-10-17 13:30:02 -04:00
Eric Radman
7c22756e66 Move restyled to a github action (#7191) 2024-10-16 09:45:25 +03:00
dependabot[bot]
a03668f5b2 Bump restrictedpython from 6.2 to 7.3 (#7181)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 13:22:44 +10:00
github-actions[bot]
e4a841a0c5 Snapshot: 24.10.0-dev 2024-10-01 00:34:37 +00:00
Zach Liu
38dc31a49b Get rid of the strange looking 0 following "Running..." and "runtime" (#7099)
* Snapshot: 24.08.0-dev

* no more Running...0 or runtime0

* also missing a space

* Restyled by prettier

* check if data_scanned is defined

otherwise we could get "Data Scanned ?" if it's not supported
by some data sources

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Restyled.io <commits@restyled.io>
2024-09-19 13:25:05 +03:00
Justin Clift
c42b15125c Automatically remove orphans when running make up (#7164) 2024-09-17 05:11:51 +00:00
dependabot[bot]
590d39bc8d Bump dompurify from 2.0.17 to 2.5.4 in /viz-lib (#7163)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 2.0.17 to 2.5.4.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/2.0.17...2.5.4)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-17 13:54:32 +10:00
Justin Clift
79bbb248bb Update make up to automatically initialise the db (#7161)
It does this by (very quickly) checking if the organization table
is present, running `make create_database` if not.
2024-09-14 16:29:04 +08:00
Zach Liu
5cf0b7b038 Better error msg for token validation (#7159)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-09-14 12:03:20 +10:00
Justin Clift
fb1a056561 Add REDASH_HOST to the docker compose file (#7157)
This ensures emails generated in the development environment have the port number included in their urls.
2024-09-12 10:06:52 +00:00
dependabot[bot]
75e1ce4c9c Bump body-parser from 1.20.1 to 1.20.3 (#7156)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.1 to 1.20.3.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.1...1.20.3)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-12 15:33:59 +10:00
dependabot[bot]
d6c6e3bb7a Bump express from 4.19.2 to 4.21.0 (#7155)
Bumps [express](https://github.com/expressjs/express) from 4.19.2 to 4.21.0.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.19.2...4.21.0)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-12 14:46:25 +10:00
dependabot[bot]
821c1a9488 Bump path-to-regexp from 3.2.0 to 3.3.0 (#7154)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-12 12:42:27 +10:00
Zach Liu
76eeea1f64 Make schema refresh timeout configurable via env var (#7114)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-09-09 01:40:35 +00:00
Justin Clift
2ab07f9fc3 Remove left over compose.base.yaml file (#7142) 2024-09-06 17:47:56 +10:00
Justin Clift
a85b9d7801 Update pymssql to fix some problems with macOS ARM64 (2.3.1) (#7140)
Related: https://github.com/pymssql/pymssql/blob/master/ChangeLog.rst
2024-09-04 17:27:02 +09:00
github-actions[bot]
3330815081 Snapshot: 24.09.0-dev 2024-09-01 00:35:07 +00:00
dependabot[bot]
c25c65bc04 Bump webpack from 5.88.2 to 5.94.0 in /viz-lib (#7135)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 13:48:45 +10:00
Eric Radman
79a4c4c9c9 Revert "Adding ability to fix table columns in place (#7019)" (#7131) 2024-08-26 22:57:47 +10:00
Justin Clift
58a7438cc8 Bump python-rapidjson to 1.20 (#7126) 2024-08-20 08:35:54 +00:00
Zach Liu
c073c1e154 Fix mismatched poetry version (#7122)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-16 16:01:53 +10:00
Justin Clift
159a329e26 Bump elliptic to version 6.5.7 to fix a Dependabot warning (#7120) 2024-08-14 14:11:38 +10:00
Ezra Odio
9de135c0bd Add option to choose color scheme for charts (#7062) 2024-08-08 13:08:49 -04:00
Zach Liu
285c2b6e56 Add data type to athena query runner (#7112)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-07 03:36:58 +00:00
dependabot[bot]
b1fe2d4162 Bump sentry-sdk from 1.28.1 to 2.8.0 (#7069)
The Dependabot alert for sentry-sdk says that the security fix has
been backported to the 1.x series as well, in version 1.45.1.

So, lets use that as it should be more compatible that jumping to
a new major series version.
2024-08-06 10:05:21 +10:00
Zach Liu
a4f92a8fb5 Add data type to redshift query runner (#7109)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-06 08:43:13 +10:00
Ezra Odio
51ef625a30 Fix alert evaluation logic and issue with calculating min and max of columns without numbers (#7103) 2024-08-05 15:20:26 +00:00
Masayuki Takahashi
a2611b89a3 Fix a display order bug in MongoDB Query Runner (#7106) 2024-08-04 04:22:59 +10:00
SeongTae Jeong
a531597016 Add the option to take new custom version for Snapshot (#7096) 2024-08-02 06:08:16 +00:00
Justin Clift
e59c02f497 Bump bootstrap to 3.4.1
Related:
- https://blog.getbootstrap.com/2018/12/13/bootstrap-3-4-0/
- https://blog.getbootstrap.com/2019/02/13/bootstrap-4-3-1-and-3-4-1/
2024-08-02 13:37:17 +09:00
github-actions[bot]
c1a60bf6d2 Snapshot: 24.08.1-dev 2024-08-02 02:49:08 +00:00
Ariel Richtman
72203655ec update rds trust (#7100) 2024-08-02 11:09:11 +10:00
Eric Radman
5257e39282 Revert "Removed unused configuration class (#6682)" (#7071) 2024-08-01 23:08:09 +00:00
Justin Clift
ec70ff4408 Bump cryptography to 42.0.x & snowflake-connector-python to 3.12.0 (#7097) 2024-08-02 08:35:36 +10:00
Masayuki Takahashi
ed8c05f634 Fix columns duplication on MongoDB Query Runner #6640 (#6641)
Co-authored-by: Konstantin Smirnov <46676677+konnectr@users.noreply.github.com>
2024-08-01 22:34:50 +00:00
Zach Liu
86b75db82e get data size in memory for better logs (#7090) 2024-08-01 12:17:54 -04:00
Ezra Odio
660d04b0f1 Adding Evaluate button for alerts to test them (#7032) 2024-08-01 15:02:12 +00:00
Ezra Odio
fc1e1f7a01 Add min/max/first selector for alerts (#7076) 2024-08-01 10:30:57 -04:00
SeongTae Jeong
8725fa4737 Add support for 'linux/arm64' platforms (#7094)
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-08-01 08:52:45 +00:00
Justin Clift
ea0b3cbe3a Add the asdf .tool-versions file to .gitignore (#7095) 2024-08-01 08:19:11 +00:00
Justin Clift
714b950fde Match FROM and AS capitalisation in Dockerfile (#7093) 2024-08-01 17:48:57 +10:00
github-actions[bot]
a9c9f085af Snapshot: 24.08.0-dev 2024-08-01 00:30:32 +00:00
Ezra Odio
a69f7fb2fe Add new text pattern parameter (#7025)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Restyled.io <commits@restyled.io>
2024-07-24 13:20:33 -04:00
Daisuke Taniwaki
c244e75352 Support Arbitrary Catalog IDs on Athena Data Source (#7059)
Co-authored-by: SeongTae Jeong <seongtaejg@gmail.com>
2024-07-24 16:57:27 +10:00
Ezra Odio
80f7ba1b91 Added option to toggle sort on pie charts (#7055)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Eric Radman <eradman@starfishstorage.com>
2024-07-22 15:13:00 +00:00
SeongTae Jeong
d2745e5acc Add a label for Restyler's PR and Bump component version (#7037) 2024-07-18 22:00:29 +00:00
Ezra Odio
4114227471 Remove defaults set during schema upgrade/downgrade (#7068) 2024-07-18 16:05:34 -04:00
Ezra Odio
8fc4ce1494 Conditionally render tooltip for Edit alert button (#7054)
* Made Edit alert tooltip render conditionally
2024-07-18 14:31:31 +00:00
Ezra Odio
ebb0e2c9ad Adding ability to fix table columns in place (#7019)
This change involved adding an extra option to the GridSettings editor,
adding the "fixed" option to columns, and adding styling for the fixed
columns. In order to change the number of fixed columns, which will
default to 0, one has to go to Edit visualization -> Grid -> Choose
number of columns to fix -> Save.
2024-07-17 13:59:47 +00:00
dependabot[bot]
57a79bc96b Bump setuptools from 69.0.3 to 70.0.0 (#7060)
Bumps [setuptools](https://github.com/pypa/setuptools) from 69.0.3 to 70.0.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v69.0.3...v70.0.0)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-16 05:45:28 +10:00
Justin Clift
77f108dd09 Bump requests to 2.32.3 (#7057) 2024-07-15 09:39:14 +09:00
dependabot[bot]
dd1a9b96da Bump zipp from 3.17.0 to 3.19.1 (#7051)
Bumps [zipp](https://github.com/jaraco/zipp) from 3.17.0 to 3.19.1.
- [Release notes](https://github.com/jaraco/zipp/releases)
- [Changelog](https://github.com/jaraco/zipp/blob/main/NEWS.rst)
- [Commits](https://github.com/jaraco/zipp/compare/v3.17.0...v3.19.1)

---
updated-dependencies:
- dependency-name: zipp
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-07-12 09:25:12 +00:00
Ezra Odio
d9282b2688 Add usedforsecurity=False flag to md5 hashes (#7049)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-07-12 03:34:53 +10:00
Eric Radman
28c39219af Update requests module to 2.32.2 (#7053)
2.32.0 was yanked
2024-07-11 16:24:18 +00:00
Ezra Odio
a37ef3b235 Fixed frontend test deprecation warnings (#7013)
Created Moment in ISO 8601 format instead of using
the default Date() constructor.

Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
2024-07-08 14:29:41 +00:00
dependabot[bot]
0056aa68f8 Bump certifi from 2023.11.17 to 2024.7.4 (#7047)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.11.17 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2023.11.17...2024.07.04)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 22:57:01 +10:00
dependabot[bot]
76b5a30fd9 Bump ws from 5.2.3 to 5.2.4 in /viz-lib (#7040)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 01:19:54 +00:00
github-actions[bot]
db4fdd003e Snapshot: 24.07.0-dev 2024-07-01 00:31:06 +00:00
Ezra Odio
4cb32fc1c3 Map() implementation fix for chart labels (#7022)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Eric Radman <eradman@starfishstorage.com>
2024-06-18 18:05:37 +00:00
dependabot[bot]
a6c728b99c Bump ws from 5.2.3 to 5.2.4 (#7021)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 02:25:45 +00:00
dependabot[bot]
01e036d0a9 Bump urllib3 from 1.26.18 to 1.26.19 (#7020)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 01:55:21 +00:00
Eric Radman
17fe69f551 PG: Only list tables where schema has USAGE permission (#7000)
This covers cases where partitioned tables are part of a schema that is
not accessible by the current user.

CREATE SCHEMA xyz;

CREATE TABLE xyz.tab (
   id bigint GENERATED ALWAYS AS IDENTITY,
   ts timestamp NOT NULL
) PARTITION BY LIST ((ts::date));

CREATE TABLE xyz.tab_default PARTITION OF xyz.tab DEFAULT;
2024-06-06 10:49:00 +10:00
Ezra Odio
bceaab0496 Update to Python 3.10 (#6991)
Updated from Python 3.8 to 3.10. Python 3.10 is the default for Ubuntu 22. This change necessitated upgrading to
SQLAlchemy_Utils 0.38.3, and importing the sort_query function from an older version of SQLAlchemy_Utils because it was dropped in newer versions.

Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
2024-06-05 17:41:49 +10:00
Lucas Fernando Cardoso Nunes
70dd05916f ci: bot identity correction (#6997)
Signed-off-by: Lucas Fernando Cardoso Nunes <lucasfc.nunes@gmail.com>
2024-06-02 06:54:08 +10:00
github-actions
60a12e906e Snapshot: 24.06.0-dev 2024-06-01 00:27:53 +00:00
dependabot[bot]
ec051a8939 --- (#6981)
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 06:50:52 +00:00
Arik Fraimovich
60d3c66a8b Merge pull request from GHSA-32fw-wc7f-7qg9 2024-05-18 07:36:29 -07:00
Justin Clift
bd4ba96c43 Typo fix in message (#6979) 2024-05-18 08:18:37 +00:00
Eric Radman
10a46fd33c Revert "show pg and athena column comments and table descriptions as antd tooltip if they are defined (#6582)" (#6971)
This reverts commit c12d45077a.

This commit did not sort tables properly by schema, then name
2024-05-16 11:28:42 +08:00
Eric Radman
c874eb6b11 Revert changes to job status (#6969)
"Query in queue" should switch to "Executing query", but does not.

Commands:

git revert --no-commit bd17662005
git revert --no-commit 5ac5d86f5e
vim tests/handlers/test_query_results.py
git add tests/handlers/test_query_results.py

Co-authored-by: Justin Clift <justin@postgresql.org>
2024-05-14 22:06:45 -04:00
Taehyung Lim
f3a323695f Bump pyodbc from 4.0.28 to 5.1.0 (#6962) 2024-05-14 16:26:28 +00:00
Eric Radman
408ba78bd0 Update MSSQL OBDC driver to v18 (#6968) 2024-05-15 01:55:32 +10:00
Eric Radman
58cc49bc88 Revert build (2 of 2) (#6967) 2024-05-14 12:30:48 +00:00
Eric Radman
753ea846ff Revert CI workflow (1 of 2) (#6965) 2024-05-14 21:54:51 +10:00
Taehyung Lim
1b946b59ec sync .nvmrc with workflow (#6958) 2024-05-10 21:32:52 +10:00
dependabot[bot]
4569191113 Bump jinja2 from 3.1.3 to 3.1.4 (#6951)
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.3...3.1.4)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 07:14:20 +10:00
Justin Clift
62890c3ec4 Revert "Remove deprecated advocate package (#6944)"
This reverts commit bd115e7f5f, as
it turns out to be a useful security feature.

In order to remove this in a better way, we'll need to replace it
with something that provides equivalent functionality.
2024-05-07 03:20:05 +10:00
Andrii Chubatiuk
bd115e7f5f Remove deprecated advocate package (#6944) 2024-05-06 23:14:13 +10:00
Andrii Chubatiuk
bd17662005 Fixed error serialization (#6937)
* serialize errors

* lint fix

* cover successful case
2024-05-02 13:52:35 +00:00
Jason Cowley
b7f22b1896 Fix 'str' object has no attribute 'pop' error when parsing query (#6941) 2024-05-02 21:31:23 +10:00
Justin Clift
897c683980 pgautoupgrade now does multi-arch builds (#6939)
Thanks to substantial efforts by @andyundso, the Docker Hub
images for pgautoupgrade are now multi-arch (x86_64 and ARM64). :)
2024-05-01 23:03:06 +10:00
github-actions
2b974e12ed Snapshot: 24.05.0-dev 2024-05-01 00:26:34 +00:00
SeongTae Jeong
372adfed6b Downgrade 'codecov-action' version from v4 to v3 (#6930)
The 'codecov-action@v4' requires an organization-level upload token, not
a single repo upload token, so we're temporarily downgrading it until we
can generate an organization-level upload token.

Reference: https://github.com/codecov/codecov-action/issues/1273
2024-04-26 08:28:20 +00:00
Eric Radman
dbab9cadb4 Source .env when running docker containers (#6927)
Restore previous functionality.

Ensure .env exists before building server.

Co-authored-by: github-actions <github-actions@github.com>
2024-04-25 11:36:03 -04:00
Kim Yann
06244716e6 Flatten all level for MongoDB data source (#6844) 2024-04-25 11:37:35 +00:00
Luciano Vitti
f09760389a aggregate Y column values rather than displaying last Y value (#6908) 2024-04-25 06:21:31 +00:00
Eric Radman
84e6d3cad5 Use staticPath var to fetch unsupportedRedirect.js (#6923)
Use Webpack configuration for locating this asset in the same way that
client/app/index.html does.

This code path is when REDASH_MULTI_ORG=true.

Co-authored-by: github-actions <github-actions@github.com>
2024-04-24 10:57:45 +00:00
Andrii Chubatiuk
3399e3761e mssql-odbc-arm64 (#6924)
Co-authored-by: Peter Lee <yankeeguyu@gmail.com>
2024-04-24 10:05:07 +00:00
Peter Lee
1c48b2218b Update widgets.py (#6926) 2024-04-24 19:37:35 +10:00
Andrii Chubatiuk
5ac5d86f5e consistent rq status naming and handling (#6913)
* consistent rq status naming and handling

* test fix

* make scheduled and deferred statuses cancelable
2024-04-24 13:15:04 +10:00
Marko Stankovic
5e4764af9c bugfix: unable to parse elasticsearch index mappings (#6918) 2024-04-23 18:13:05 +08:00
Eric Radman
e2a39de7d1 Remove workaround from check_csrf() (#6919)
This code was supposed to be temporary, and raises an exception if REDASH_MULTI_ORG=true is set.
2024-04-23 13:14:45 +10:00
Justin Clift
6c68b48917 Add pydeps Makefile target for installing Python dependencies (#6890)
This combines the manual steps needed for installing the Python dependencies into a single Makefile target.
2024-04-18 22:35:01 +10:00
Andrii Chubatiuk
7e8a61c73d Rq upgrade (#6902)
* fix(aws-es): fixed es auth

* fixed lock

* rq v1.16
2024-04-17 17:46:32 +10:00
dependabot[bot]
991e94dd6a Bump gunicorn from 21.2.0 to 22.0.0 (#6900)
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.2.0 to 22.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/21.2.0...22.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-17 14:00:59 +10:00
Andrii Chubatiuk
2ffeecb813 fix: aws elasticsearch typo (#6899)
Co-authored-by: Peter Lee <yankeeguyu@gmail.com>
2024-04-17 02:49:39 +00:00
dependabot[bot]
3dd855aef1 Bump sqlparse from 0.4.4 to 0.5.0 (#6895)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.4 to 0.5.0.
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.4...0.5.0)

---
updated-dependencies:
- dependency-name: sqlparse
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 13:06:57 +10:00
Justin Clift
713aca440a Extend make up to automatically initialise the database (#6855) 2024-04-13 14:47:41 +10:00
dependabot[bot]
70bb684d9e Bump dnspython from 2.4.2 to 2.6.1 (#6886)
Bumps [dnspython](https://github.com/rthalley/dnspython) from 2.4.2 to 2.6.1.
- [Release notes](https://github.com/rthalley/dnspython/releases)
- [Changelog](https://github.com/rthalley/dnspython/blob/main/doc/whatsnew.rst)
- [Commits](https://github.com/rthalley/dnspython/compare/v2.4.2...v2.6.1)

---
updated-dependencies:
- dependency-name: dnspython
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 13:46:38 +10:00
dependabot[bot]
4034f791c3 Bump pymongo from 4.3.3 to 4.6.3 (#6863)
Bumps [pymongo](https://github.com/mongodb/mongo-python-driver) from 4.3.3 to 4.6.3.
- [Release notes](https://github.com/mongodb/mongo-python-driver/releases)
- [Changelog](https://github.com/mongodb/mongo-python-driver/blob/master/doc/changelog.rst)
- [Commits](https://github.com/mongodb/mongo-python-driver/compare/4.3.3...4.6.3)

---
updated-dependencies:
- dependency-name: pymongo
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 03:14:35 +00:00
Justin Clift
b9875a231b Improve the text displayed when using the command line (#6884)
This removes some debugging output, and makes an unexpected text
string useful by explaining what's happening.
2024-04-13 11:51:15 +10:00
Justin Clift
062a70cf20 Change default webUI port back to 5001 (#6883)
This PR changes the default (tcp) port for the web user interface back to port 5001.

The recent change to port 5000 (to match an old default) turned out to be more painful than it's worth.

So, lets keep using port 5001 after all.
2024-04-13 02:02:55 +10:00
Andrii Chubatiuk
c12d45077a show pg and athena column comments and table descriptions as antd tooltip if they are defined (#6582)
* show column comments by default for athena and postgres

* Restyled by prettier

* fixed typo

* fmt fix

* ordered imports

* fixed unit tests

* fixed tests for athena

---------

Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-12 21:02:15 +10:00
snickerjp
6d6412753d Bump python-oracledb from 2.0.1 to 2.1.2 (#6881) 2024-04-12 10:33:16 +00:00
Andrii Chubatiuk
275e12e7c1 fix: unquote values in compose (#6882) 2024-04-12 20:05:24 +10:00
Andrii Chubatiuk
77d7508cee fixed local setup to run on ARM64 (#6877)
* fixed local setup to run on ARM64

* set local profile in makefile by default

* reverted compose comment for postgres command
2024-04-12 08:10:34 +00:00
Justin Clift
9601660751 Update Node image in Dockerfile to 18-bookworm 2024-04-12 15:08:56 +10:00
dependabot[bot]
45c6fa0591 Bump idna from 3.6 to 3.7 (#6878)
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

---
updated-dependencies:
- dependency-name: idna
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-12 14:58:04 +10:00
Andrii Chubatiuk
95ecb8e229 fix for coverage (#6872)
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-11 05:35:14 +00:00
dependabot[bot]
cb0707176c Bump tar from 6.1.15 to 6.2.1 (#6866)
Bumps [tar](https://github.com/isaacs/node-tar) from 6.1.15 to 6.2.1.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.1.15...v6.2.1)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-11 06:47:08 +10:00
Andrii Chubatiuk
d7247f8b84 use default docker repo name if variable is not defined (#6870)
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-11 06:15:43 +10:00
Andrii Chubatiuk
776703fab7 filter widget results to fix tests during repeatable execution (#6693)
* filter widged results to fix tests during repeatable execution

* minor fix

* made queryName variable

---------

Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-04-10 23:14:47 +10:00
Andrii Chubatiuk
34cde71238 fix percy for a branch (#6868)
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-10 21:10:41 +10:00
Andrii Chubatiuk
f631075be3 reverted e2e secrets (#6867)
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-10 20:16:31 +10:00
Andrii Chubatiuk
3f19534301 reuse built frontend in ci, merge compose files (#6674)
* reuse built frontend in ci, merge compose files

* pr comments

* added make create_db alias to create_database

* fixed lint

---------

Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-10 19:53:14 +10:00
Justin Clift
24dec192ee Update yarn to current latest in 1.22.x series (#6858)
* Update yarn to current latest in 1.22.x series

* Use an environment variable for the yarn version

As suggested by @lucydodo:

  https://github.com/getredash/redash/pull/6858#discussion_r1555131358

Thanks heaps. :)
2024-04-08 01:52:45 +00:00
Ran Benita
82d88ed4eb Bump gunicorn from 20.0.4 to 21.2.0 (#6856)
The version 20.0.4 has a security issue:
https://grenfeldt.dev/2021/04/01/gunicorn-20.0.4-request-smuggling/

Changelog:
https://docs.gunicorn.org/en/stable/news.html
2024-04-08 06:26:02 +10:00
Justin Clift
af0773c58a Update "make clean" to remove Redash dev Docker images (#6847)
Also added a "make clean-all" target to remove the related containers
2024-04-07 12:14:38 +10:00
Justin Clift
15e6583d72 Automatically use the latest version of PostgreSQL (#6851) 2024-04-05 10:46:12 -04:00
Justin Clift
4eb5f4e47f Remove version check and all of the data sharing (#6852) 2024-04-06 00:02:31 +10:00
Eric Radman
a0f5c706ff Remove Qubole query runner (#6848)
The qds-sdk-py package along with the rest of the Qubole project is no longer
maintained:

3c6a34ce33

Removing this eliminates these warnings when running Redash management commands:

./qds_sdk/commands.py:1124: SyntaxWarning: "is" with a literal. Did you mean "=="?
  if options.mode is "1":
./qds_sdk/commands.py:1137: SyntaxWarning: "is" with a literal. Did you mean "=="?
  if options.db_update_mode is "updateonly":
./qds_sdk/commands.py:1424: SyntaxWarning: "is" with a literal. Did you mean "=="?
  if (total is 0) or (downloaded == total):

Co-authored-by: github-actions <github-actions@github.com>
2024-04-04 07:52:18 +10:00
Will Lachance
702a550659 Handle timedelta in query results (#6846) 2024-04-03 15:44:08 +00:00
Eric Radman
38a06c7ab9 Autoformat hyperlinks in Slack alerts (#6845)
Format the Slack message using the "mrkdwn" type, which will make
hyperlinks clickable.

New test for Slack destination.

Co-authored-by: github-actions <github-actions@github.com>
2024-04-03 01:31:55 +00:00
Eric Radman
a6074878bb Use setup-python@v5, setup-node@v4 (#6842)
To avoid warnings in the CI pipeline

> Node.js 16 actions are deprecated. Please update the following actions to use Node.js 20

Co-authored-by: github-actions <github-actions@github.com>
2024-04-01 23:37:37 +00:00
github-actions
fb348c7116 Snapshot: 24.04.0-dev 2024-04-01 00:27:13 +00:00
Will Lachance
24419863ec Handle decimal types in query results (#6837)
Since #6687, we don't serialize query results as JSON
before returning them. This is fine, except for the
query results data source which needs to pass the
data directly to sqlite3, and doesn't know how to
do that with the decimal types that are occasionally
returned by (at least) the PostgreSQL query runner:

https://www.psycopg.org/docs/faq.html#problems-with-type-conversions
2024-03-29 17:51:14 +10:00
dependabot[bot]
c4d3d9c683 Bump express from 4.18.2 to 4.19.2 (#6838)
Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 17:14:56 +10:00
dependabot[bot]
1672cd9280 Bump webpack-dev-middleware from 5.3.3 to 5.3.4 (#6829)
Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-24 10:20:14 +10:00
Eric Radman
6575a6499a BigQuery: use default for useQueryAnnotation option (#6824)
This option may not be set after an upgrade

Co-authored-by: github-actions <github-actions@github.com>
2024-03-21 20:36:09 +00:00
dependabot[bot]
e360e4658e Bump jwcrypto from 1.5.1 to 1.5.6 (#6816)
Bumps [jwcrypto](https://github.com/latchset/jwcrypto) from 1.5.1 to 1.5.6.
- [Release notes](https://github.com/latchset/jwcrypto/releases)
- [Commits](https://github.com/latchset/jwcrypto/compare/v1.5.1...v1.5.6)

---
updated-dependencies:
- dependency-name: jwcrypto
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-20 07:40:23 +10:00
dependabot[bot]
107933c363 Bump follow-redirects from 1.15.5 to 1.15.6 in /viz-lib (#6813)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Lee <yankeeguyu@gmail.com>
2024-03-19 23:38:47 +10:00
Vladislav Denisov
667a696ca5 ClickHouse query runner: fixed error message (#6764)
* Snapshot: 23.11.0-dev

* Snapshot: 23.12.0-dev

* Snapshot: 24.01.0-dev

* Snapshot: 24.02.0-dev

* clickhouse: check for `exception` field in response

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Vladislav Denisov <denisov@sports.ru>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
Co-authored-by: Peter Lee <yankeeguyu@gmail.com>
2024-03-18 02:15:49 +10:00
Robin Edwards
7d0d242072 schedule may not contain an until key (#6771) 2024-03-15 09:00:19 +10:00
Arun Govind M
d554136f70 fix: Uncaught rejection promise error in Edit Visualization Dialog Modal (#6794) 2024-03-07 10:01:51 +10:00
Stefan Negele
34723e2f3e Add RisingWave support (#6776) 2024-03-05 17:29:47 +08:00
dependabot[bot]
11794b3fe3 Bump es5-ext from 0.10.53 to 0.10.63 in /viz-lib (#6782)
Bumps [es5-ext](https://github.com/medikoo/es5-ext) from 0.10.53 to 0.10.63.
- [Release notes](https://github.com/medikoo/es5-ext/releases)
- [Changelog](https://github.com/medikoo/es5-ext/blob/main/CHANGELOG.md)
- [Commits](https://github.com/medikoo/es5-ext/compare/v0.10.53...v0.10.63)

---
updated-dependencies:
- dependency-name: es5-ext
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-02 04:46:01 +00:00
Eric Radman
3997916d77 Always push images to hub.docker.com/u/redash (#6792)
Using github.repository_owner name was convenient for testing this
action, but is not correct since account names do not match.

Git Hub: getredash/
Docker Hub: redash/

Co-authored-by: github-actions <github-actions@github.com>
2024-03-01 21:42:15 +00:00
dependabot[bot]
b09a2256dc Bump es5-ext from 0.10.53 to 0.10.63 (#6784)
Bumps [es5-ext](https://github.com/medikoo/es5-ext) from 0.10.53 to 0.10.63.
- [Release notes](https://github.com/medikoo/es5-ext/releases)
- [Changelog](https://github.com/medikoo/es5-ext/blob/main/CHANGELOG.md)
- [Commits](https://github.com/medikoo/es5-ext/compare/v0.10.53...v0.10.63)

---
updated-dependencies:
- dependency-name: es5-ext
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-01 18:51:54 +00:00
Eric Radman
95a45bb4dc Snapshot: 24.03.0-dev (#6791)
Co-authored-by: github-actions <github-actions@github.com>
2024-03-02 04:21:22 +10:00
Eric Radman
7cd03c797c Publish preview Docker image when release candidate is tagged (#6787)
* Only respond to new tags ending with -dev
* Use github account name to allow easier testing in a fork
* Allow preview image to be referenced by a specific tag, or by latest tag

redash/preview:24.02.0-dev
redash/redash:preview

Co-authored-by: github-actions <github-actions@github.com>
2024-03-01 16:05:12 +00:00
Eric Radman
1200f9887a Use SSH deployment key to bump version and tag release candiate (#6789)
To allow this workflow to run even though normal contributors
are required to create a pull request.

Steps:

1. Generate SSH key pair: ssh-keygen -t ed25519. No need for passphrases etc.
2. Add public key (.pub one) as a deploy key at Your repo -> Settings ->
   Security -> Deploy keys, check "Allow write access".
3. Add private key as a secret at Your repo -> Settings -> Security -> Secrets
   and variables -> Actions

https://stackoverflow.com/a/76135647/1809872

Co-authored-by: github-actions <github-actions@github.com>
2024-03-02 00:51:51 +10:00
Dirk van Donkelaar
81d22f1eb2 Add limit option for MSSQL query runner (#6704)
* Add limit option for MSSQL query runner

* Fixed linting errors
2024-02-27 06:16:54 +10:00
Eric Radman
2fe0326280 Allow WebPack configuration when building in Docker (#6780)
Co-authored-by: github-actions <github-actions@github.com>
2024-02-27 05:28:39 +10:00
Andrii Chubatiuk
094984f564 Node 18 (#6752)
* Snapshot: 24.02.0-dev

* node-18

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-02-22 22:10:20 +00:00
dependabot[bot]
52cd6ff006 Bump axios from 0.27.2 to 0.28.0 in /viz-lib (#6775)
Bumps [axios](https://github.com/axios/axios) from 0.27.2 to 0.28.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.28.0/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.27.2...v0.28.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-23 04:01:43 +10:00
Andrii Chubatiuk
939bec2114 Load custom encoder only when runner enabled (#6748)
* Snapshot: 24.02.0-dev

* load encoders only for enabled runners

* try importing within init

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2024-02-09 21:49:59 -05:00
Andrii Chubatiuk
320fddfd52 Changed checkout commit (#6749)
* Snapshot: 24.02.0-dev

* changed checkout commit

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-02-07 03:15:13 +10:00
Guido Petri
ab39283ae6 fix linting (#6745) 2024-02-05 19:47:25 -06:00
Eric Radman
6386905616 Revert example message for "Custom rule for hiding filter" (#6709)
Partialy reverts
 Hide filter components on shared pages
 https://github.com/getredash/redash/pull/6115

The "hide_filter" feature is incomplete, and the example text is
confusing, adds clutter to the share dashboard dialog.

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2024-02-05 20:38:17 -05:00
Andrii Chubatiuk
d986b976e5 fixed custom json encoders (#6741)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-02-05 20:34:45 -05:00
Lucas Fernando Cardoso Nunes
a600921c0b feat: avoid npm usage (#6742)
Signed-off-by: Lucas Fernando Cardoso Nunes <lucasfc.nunes@gmail.com>
2024-02-05 20:31:26 -05:00
Lucas Fernando Cardoso Nunes
af2f4af8a2 refactor: use docker compose recommendations (#5854)
Signed-off-by: Lucas Fernando Cardoso Nunes <lucasfc.nunes@gmail.com>
2024-02-04 07:22:40 -06:00
Eric Radman
49a5e74283 Snapshot: 24.02.0-dev (#6740)
Co-authored-by: github-actions <github-actions@github.com>
2024-02-03 01:03:46 +00:00
Andrii Chubatiuk
b98b5f2ba4 Switch to pull_request_target events to hide cypress secrets (#6716)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-01-30 14:51:46 +10:00
Dirk van Donkelaar
d245ff7bb1 Fixed notification template (#6721)
* Fixed notification template

* Made if-clause equal to append

Like Slack and email notification

* Add custom_body attribute to discord test

* Add missing attribute
2024-01-29 22:15:42 -05:00
Andrii Chubatiuk
97db492531 Removed unused configuration class (#6682)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-18 21:50:27 +10:00
Dirk van Donkelaar
30e7392933 Fix UnicodeEncodeError for non-Latin characters (#6715) 2024-01-18 08:15:11 +00:00
Andrii Chubatiuk
a54171f2c2 cast query_results data back to text (#6713)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-18 11:13:47 +10:00
Robin Edwards
cd03da3260 Cast to JSONB type to match altered column type (#6707) 2024-01-16 02:23:35 +10:00
dependabot[bot]
4c47bef582 Bump jinja2 from 3.1.2 to 3.1.3 (#6700)
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.2...3.1.3)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-01-12 10:08:25 +10:00
Andrii Chubatiuk
ec1c4d07de Removed pseudojson class, converted all options and other json columns to jsonb ones (#6687)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-12 09:02:00 +10:00
Andrii Chubatiuk
4d5103978b Removed simplejson (#6685)
* removed simplejson

* minor fix

* fixed lint

---------

Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-11 17:47:33 +10:00
dependabot[bot]
3c2c2786ed Bump follow-redirects from 1.15.2 to 1.15.4 in /viz-lib (#6699)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-10 21:15:45 +00:00
snickerjp
cd482e780a Bump python-oracledb from 1.4.0 to 2.0.1 (#6698) 2024-01-10 20:44:18 +00:00
Eric Radman
4d81c3148d Update query hash with parameters applied (#6683)
This allows queries with parameters to run on a schedule since the hash
used to update the query_result will match.

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2024-01-10 03:46:31 +00:00
Eric Radman
1b1b9bd98d Import all Plotly visualizations (#6673)
- Accessible using the Custom chart type
- Disable 'fs' and 'path' modules which are available in node, but not
  on the frontend.

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2024-01-07 20:19:58 +00:00
dependabot[bot]
473cf29c9f Bump pycryptodome from 3.19.0 to 3.19.1 (#6694)
Bumps [pycryptodome](https://github.com/Legrandin/pycryptodome) from 3.19.0 to 3.19.1.
- [Release notes](https://github.com/Legrandin/pycryptodome/releases)
- [Changelog](https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst)
- [Commits](https://github.com/Legrandin/pycryptodome/compare/v3.19.0...v3.19.1)

---
updated-dependencies:
- dependency-name: pycryptodome
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-06 10:33:30 +10:00
Andrii Chubatiuk
cbde237b12 removed explicit object inheritance (#6686)
* removed explicit object inheritance

* minor fix

* pr comments

---------

Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-05 19:52:02 +09:00
Eric Radman
998dc31eb0 Snapshot: 24.01.0-dev (#6681)
Co-authored-by: github-actions <github-actions@github.com>
2024-01-03 00:43:01 +10:00
dependabot[bot]
2505e8ab3b Bump jwcrypto from 1.5.0 to 1.5.1 (#6679)
Bumps [jwcrypto](https://github.com/latchset/jwcrypto) from 1.5.0 to 1.5.1.
- [Release notes](https://github.com/latchset/jwcrypto/releases)
- [Commits](https://github.com/latchset/jwcrypto/compare/v1.5.0...v1.5.1)

---
updated-dependencies:
- dependency-name: jwcrypto
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-29 11:32:41 +09:00
fabrei
858fc4d78f Adds influxdb v2 query runner as a default query runner. (#6669)
* Adds influxdb v2 query runner as a default query runner.

* Deletes TableList object for typing, because it is possible, that the import of influxdb client fails.

---------

Co-authored-by: Masayuki Takahashi <masayuki038@gmail.com>
2023-12-20 21:46:12 +09:00
dependabot[bot]
3e500ea18e Bump paramiko from 3.3.1 to 3.4.0 (#6670)
Bumps [paramiko](https://github.com/paramiko/paramiko) from 3.3.1 to 3.4.0.
- [Commits](https://github.com/paramiko/paramiko/compare/3.3.1...3.4.0)

---
updated-dependencies:
- dependency-name: paramiko
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-20 19:21:40 +09:00
fabrei
58bf96c298 Adds ssl support for prometheus query runner. (#6657)
* Adds ssl support for prometheus query runner.

- Adds possibilty to upload and use of ssl cert, key and ca file in redash ui

* Extends test cases for prometheus query runner.

- Adds secret attribute to configuration schema.

* Fixes wrong timestamps in different timezones in prometheus' testcases.

- Dynamically calculates timestamps in testcases to be robust in
  different timezones.
- Adds now datetime function to make it more testable.

* Fixes timestamp in prometheus' testcases which can be wrong depending on timezone.

---------

Co-authored-by: Masayuki Takahashi <masayuki038@gmail.com>
2023-12-17 21:58:16 +09:00
fabrei
66ef942572 Adds influxdb v2 query runner. (#6646)
* Adds influxdb v2 query runner.

- Adds test cases
- Adds influxdb v2 icon
- Updates python dependencies

* Fixes import order.

* Fixes code formatting for black tool.

* Adds influxdb version 2 support in readme.

---------

Co-authored-by: Fabian Reiber <reiber@dfn-cert.de>
Co-authored-by: Masayuki Takahashi <masayuki038@gmail.com>
2023-12-12 23:01:50 +09:00
Masayuki Takahashi
9bbdb4b765 Show an error message on "Test Connection" failure for Google Spreadsheet Query Runner (#6652) 2023-12-10 00:42:46 +09:00
Eric Radman
2b4b1cf7e3 Snapshot: 23.12.0-dev (#6638)
Co-authored-by: github-actions <github-actions@github.com>
2023-12-06 09:51:57 +00:00
dependabot[bot]
9b29f26217 Bump cryptography from 41.0.4 to 41.0.6 (#6630)
Bumps [cryptography](https://github.com/pyca/cryptography) from 41.0.4 to 41.0.6.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/41.0.4...41.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 00:39:58 +00:00
Eric Radman
392b930f2d Revert "Switch from numeral to numbro (#6344)" (#6595)
This reverts commit f8934b8312.

Using a format string of '0' does not round to the nearest integer in Numbro
https://github.com/BenjaminVanRyseghem/numbro/issues/745

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-11-25 14:36:08 +00:00
anushae6
9df6f80bb7 update readme to add e6data (#6620) 2023-11-24 05:11:37 -06:00
dependabot[bot]
f7b47c0436 Bump werkzeug from 2.3.6 to 2.3.8 (#6616)
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 2.3.6 to 2.3.8.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/2.3.6...2.3.8)

---
updated-dependencies:
- dependency-name: werkzeug
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-22 21:39:39 -05:00
anushae6
09addaadc3 add e6data query runner with test cases (#6604)
* add e6data query runner with test cases

* added more test cases
2023-11-22 20:59:21 -05:00
Vladislav Denisov
a07b8a6bd3 Yandex.Disk Query runner (#6598)
* Snapshot: 23.11.0-dev

* dataframe_to_result function moved outside python query runner

* added yandex disk query runner

* moved file_extension check

* skip unsupported extensions in schema

* removed unused variable

* added support for xlsx with multiple sheets

* moved pandas-converters to utils file

* added tests

* fixed backend tests

* fixed pandas to redash type conversion

* added more tests

* added tests for pandas

* added tests for pandas converter and yandex disk

* added tests for read_file and multiple sheets

* pandas: do not load if lib is not installed

* added test for yaml read

* fixed test for yaml read

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-11-21 21:16:29 -05:00
Genki Sugawara
8bfc57430d chore: Fix ci.yml typo: ghq -> gha (#6601) 2023-11-15 21:57:50 +09:00
Luka Lovosevic
a8c6dd0043 Update rockset.py - replace default API server URL (#6592)
Replaced default API server URL with new URL in USW2 region
2023-11-14 01:01:52 +09:00
Peter Lee
2d879510e4 add fork dashboard function (#6588)
* add fork dashboard function

* add test

* fix

---------

Co-authored-by: guyu <guyu@fordeal.com>
2023-11-11 20:56:47 +00:00
Luka Lovosevic
13e61fc3a0 Update rockset.py with support for multi-VI (#6584)
* Update rockset.py with support for multi-VI

Rockset introduced compute-compute separation which allows multiple Virtual Instances (VIs) to query data independently. This a commit introduces support for executing queries on specific VIs from Redash.

Changes include:
- added new configuration element (not required): Virtual Instance ID
- if this VI ID is configured, Redash will use that specific VI to execute the query
- if it's not specified, Redash will execute the query on the main/default VI (same behaviour as before)

* Update rockset.py

Removed comment from the query method to pass lint test

* Update rockset.py - for linting

Added more formatting to pass the black lint test

* Update rockset.py - linting update

* Update rockset.py - updated lint
2023-11-10 22:38:47 +09:00
Masayuki Takahashi
de1958e995 Bump snowflake-connector-python 3.4.0 for fixing 'Error detecting the version of libcrypto' (#6581)
* Bump snowflake-connector-python 3.4.0 for fixing 'Error detecting the
version of libcrypto'

* Update poetry.lock and remove .python-version
2023-11-07 20:09:52 -05:00
Masayuki Takahashi
198b422eaf Display catalog information on the schema pane when connecting to Trino (#6578) 2023-11-06 20:58:12 -05:00
Eric Radman
63cef6632e Snapshot: 23.11.0-dev (#6571) 2023-11-01 10:38:23 -04:00
myonlylonely
2611dcc0f1 Bump protobuf from 3.18.3 to 3.20.2 (#6568) 2023-10-31 15:52:41 +00:00
Dominik Sigmund
55193fbf66 Changed asc_url to entity_id (#6202)
Respect the User Setting instead of a hardcoded  value

Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-10-31 11:23:00 +00:00
Eric Radman
8b8dd4f68c Revert "Render counter widgets using relative font size" (#6566) 2023-10-31 10:27:09 +10:00
Guido Petri
ae77e72821 fix falsey comparison to determine whether a dashboard is archived. closes #6560 (#6561) 2023-10-30 01:06:04 +10:00
Guido Petri
39e4ea155c Support multi column results with using mongo query runner (#6558)
* Support multi column results with using mongo query runner

* format

Signed-off-by: del-zhenwu <lizhenxiang@pjlab.org.cn>

* Update test_mongodb.py

update unit test case: nested dict for mongodb runner

* Update test_mongodb.py

* fix formatting

---------

Signed-off-by: del-zhenwu <lizhenxiang@pjlab.org.cn>
Co-authored-by: del-zhenwu <dele.zhenwu@gmail.com>
Co-authored-by: del-zhenwu <lizhenxiang@pjlab.org.cn>
2023-10-28 21:13:17 -04:00
dependabot[bot]
a5b01bf8ee Bump browserify-sign from 4.0.4 to 4.2.2 (#6557)
Bumps [browserify-sign](https://github.com/crypto-browserify/browserify-sign) from 4.0.4 to 4.2.2.
- [Changelog](https://github.com/browserify/browserify-sign/blob/main/CHANGELOG.md)
- [Commits](https://github.com/crypto-browserify/browserify-sign/compare/v4.0.4...v4.2.2)

---
updated-dependencies:
- dependency-name: browserify-sign
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-27 19:20:36 -04:00
Justin Clift
5516b427d8 Remove pygridgain so Trino data source shows up (#6546)
This also updates the python tzlocal dependency to the 4.x series,
as seems to be required for Trino to work.
2023-10-23 18:34:55 -04:00
deecay
de84c40868 Better Heatmap annotation color (#6543) 2023-10-23 03:25:27 +00:00
Kieran Molloy
39766a2d97 Reset redis pipeline at end of enqueue_query (#6540) 2023-10-20 22:12:55 -05:00
Eric Radman
593b6ae6ed Specify counter widgets font size in pt (#6537)
- prevent scrollbars from appearing
- start with a base font size of 12pt for more even scaling

Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-10-19 02:35:58 +00:00
Adriano Pereira Junior
8bb1767c69 Add new conn options in MySQL (#6538) 2023-10-19 01:11:11 +00:00
Ken Michalak
7b03e60f9d add pagination and base_url to JSON query_runner (#6499) 2023-10-18 08:08:37 -05:00
dependabot[bot]
ac9f24a781 Bump urllib3 from 1.26.17 to 1.26.18 (#6534)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.17 to 1.26.18.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.17...1.26.18)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-17 20:15:04 -05:00
Justin Clift
54c4a4249a Revert arm64 docker image building in CI (for now) (#6532)
This PR reverts PRs #6525 and #6531, due to problems building
the arm64 images with GitHub Actions
2023-10-17 20:21:30 -04:00
dependabot[bot]
36dd3e9609 Bump @babel/traverse from 7.22.8 to 7.23.2 (#6530)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.8 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-10-17 14:35:35 +00:00
Genki Sugawara
69d1e03e60 Update yarn network-timeout: 30s(default) -> 600s (#6531) 2023-10-17 11:46:37 +00:00
dependabot[bot]
a2c0c488eb Bump @babel/traverse from 7.22.8 to 7.23.2 in /viz-lib (#6529)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.8 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-17 03:45:29 +00:00
fukatani
ddbe0f6ce5 Display bigquery subfield of record as schema. (#6522)
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-10-16 23:04:41 -04:00
Vladislav Denisov
42108089ed Metrica: retry query if quota exceeded (#6459)
* metrica: added retries

* updated poetry.lock

* use poetry v1.6.1

* added simple test

* convert unittest to pytest

* add 429 test

* fix 429 status code response?

---------

Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-10-16 21:53:21 -05:00
Genki Sugawara
d4ade51fba Add linux/arm64 to docker image platforms (#6525)
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-10-16 12:58:19 +00:00
Genki Sugawara
84d1693419 Update Debian image in Dockerfile (#6524) 2023-10-16 08:51:38 +00:00
Genki Sugawara
12f1050000 chore: Remove "Datree" badge from README (#6526) 2023-10-16 17:38:14 +10:00
Eric Radman
6b981972f0 Allow Query.options to be None (#6519)
* Allow Query.options to be None

Query.options may not have a value on a database created by 10.1.0

* Ensure counter widget is not compressed in query preview tabs

Also bump minimum counter font size to 14
2023-10-16 11:07:30 +10:00
Leandro Lomónaco
eafe30d52c Bigger textarea for query description (#6518) 2023-10-13 15:00:48 +10:00
Ben Best
abbd4d3146 Create sqlalchemy_searchable expressions (#6491)
The required sql expressions for sqlalchemy_searchable are only created
on the sqlalchemy pre_create hook. When upgrading from a previous redash
version no CREATE statements may be run, so these expressions are never
created.

Add a migration to manually execute the sql_expressions DDL from
sqlalchemy_searchable.
2023-10-12 17:43:20 +00:00
Eric Radman
1d350853bd Commit version update and tag together (#6513)
Also update to checkout v4
2023-10-09 14:37:16 -04:00
Eric Radman
3edf7790fc Snapshot: 23.10.0-dev (#6512) 2023-10-09 09:55:48 -04:00
Masayuki Takahashi
011f9ef311 Add column type to a query result of InfluxDB (#6505)
* Add column type to a query result of InfluxDB

* Remove an unused import

* Migrate to pytest

* Rename test names to descriptive

---------

Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-10-08 14:39:51 +00:00
Guido Petri
138339a8a4 convert docker-compose to docker compose (#6504) 2023-10-08 12:06:48 +10:00
Guido Petri
0f175b7a5b build and push image only on master branch (#6507) 2023-10-08 10:55:15 +10:00
Guido Petri
0c2dc4e025 fix dockerhub image building (#6503) 2023-10-07 02:22:55 +00:00
Vladislav Denisov
a19b17b844 Fixed embedded queries (#6497)
* refactored users models

* added tests
2023-10-04 15:12:17 +10:00
dependabot[bot]
09ec299e65 Bump urllib3 from 1.26.16 to 1.26.17 (#6495)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.16 to 1.26.17.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.16...1.26.17)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-03 17:24:11 +10:00
dependabot[bot]
9461bf6479 Bump debug from 3.2.6 to 3.2.7 in /viz-lib (#6493)
Bumps [debug](https://github.com/debug-js/debug) from 3.2.6 to 3.2.7.
- [Release notes](https://github.com/debug-js/debug/releases)
- [Commits](https://github.com/debug-js/debug/compare/3.2.6...3.2.7)

---
updated-dependencies:
- dependency-name: debug
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-03 03:08:58 +00:00
dependabot[bot]
1ae4e20d70 Bump debug from 3.2.6 to 3.2.7 (#6494)
Bumps [debug](https://github.com/debug-js/debug) from 3.2.6 to 3.2.7.
- [Release notes](https://github.com/debug-js/debug/releases)
- [Commits](https://github.com/debug-js/debug/compare/3.2.6...3.2.7)

---
updated-dependencies:
- dependency-name: debug
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-03 02:05:37 +00:00
Eric Radman
3d32c55531 Avoid updating query result for archived queries (#6488) 2023-09-29 13:46:02 +10:00
Genki Sugawara
4a36abc628 Add Datadog alert destination (#6476)
* Add Datadog alert destination

* Fix Datadog Event API response code: 200 -> 202

* Add datadog alert dest test

* Sort test_destinations.py imports

* Fix test_datadog_notify_calls_requests_post

* Fix datadog alert dest: Add aggregation_key
2023-09-27 20:22:00 -04:00
George Spake
3ebf163c29 Update gevent to 23.9.1 to address CVE (#6487)
update greenlet to 2.0.2 - 
Because redash depends on gevent (23.9.1) which depends on greenlet (>=2.0.0), greenlet is required.
So, because redash depends on greenlet (1.1.3), version solving failed.
2023-09-27 18:56:24 +00:00
dependabot[bot]
c3c54f6ca2 Bump cryptography from 41.0.3 to 41.0.4 (#6474)
Bumps [cryptography](https://github.com/pyca/cryptography) from 41.0.3 to 41.0.4.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/41.0.3...41.0.4)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-22 08:50:49 +10:00
Justin Clift
b13772c464 Update poetry.lock after PR 6469 (#6473)
Without this, poetry is complaining about a mismatch
2023-09-22 01:57:12 +10:00
Vladislav Denisov
74b0f8bb58 bump gpsread == 5.11.2 (#6469) 2023-09-20 19:42:40 -05:00
Justin Clift
953ed8431b Revert PR 6448 (SQLAlchemy-Utils update) for now (#6466) 2023-09-20 20:51:38 +10:00
Vladislav Denisov
0ca72d27e6 bump ldap3 = 2.9.1 (#6463) 2023-09-20 15:29:17 +10:00
Justin Clift
650ec90df3 Add kerberos development headers to Redash image (#6454)
Without these headers, compiling pymssql on arm64 fails:

  gcc -pthread -shared build/temp.linux-aarch64-cpython-38/src/pymssql/_mssql.o -L/usr/local/lib -lsybdb -lgssapi_krb5 -lkrb5 -lssl -lcrypto -o build/lib.linux-aarch64-cpython-38/pymssql/_mssql.cpython-38-aarch64-linux-gnu.so
  /usr/bin/ld: cannot find -lgssapi_krb5
  /usr/bin/ld: cannot find -lkrb5
  collect2: error: ld returned 1 exit status
2023-09-14 21:54:13 +10:00
Eric Radman
b84587931f Add ruff to Python dev dependencies (#6450) 2023-09-13 08:47:45 +10:00
Eric Radman
ff85a36f50 Avoid file locking issue when running CLI test (#6449)
__file__ will resolve to tests/test_cli.py, which is already opened by Python.
When tests are run on a network file system the test runner may deadlock while
waiting for an advisory lock to be released on this file.
2023-09-12 18:44:24 -04:00
Eric Radman
6d91c64dae Update SQLAlchemy-Utils to 0.36.5 (#6448)
This makes the Python unit tests compatible with Python 3.10
2023-09-12 13:32:58 +00:00
Eric Radman
ca36130e76 Update package.lock for updated python versions (#6446)
Output of `poetry update requires-python`
2023-09-12 02:53:00 +00:00
Guido Petri
0993f68fa0 Poetry conversion followup (#6440)
* change author and add maintainers

* remove bin/upgrade

* update project author/maintainers

* comment on how to get added/removed from the mailing list
2023-09-11 22:25:54 -04:00
Daniel Stevenson
f109af9f30 Check all Trino catalogs if no catalog configured (#5860)
* Check all Trino catalogs if no catalog configured

- If catalog is not set, query 'SHOW CATALOGS' to get catalogs
- For each catalog, discover schema, table, columns
- If catalog has a `.`, then quote the catalog name
- New table name returned includes catalog name
- If catalog is set, same result as before

* Use self._handle_run_query_error(error)

* Fix lint problem & format w/ black

* Fix condition so catalog_prefixes are [""] when catalog is set

* Fix for loop / Remove default values for catalog & schema

* Add untested tests for Trino's get_schema

* Black formatting

* Add test_get_schema_catalog_set test & fix tests

* Parameterize the side_effect fn thoroughly

* Get catalogs (not catalog_prefixes) bc confusing

* Trino._get_catalogs added for testability
2023-09-11 21:16:07 -04:00
Eric Radman
b4e4a5a928 Accept Python 3.8-3.10 (#6445)
For running a development environment on Ubuntu 22
2023-09-11 15:26:31 -04:00
Will Lachance
ca900769c2 Replace flake8/isort with ruff (#6441)
There's a few advantages of using ruff over these tools:

* It's way faster
* It's easier to configure
* It includes support for a bunch of other linters (for example bugbear)
  right out of the box, which catches some things and makes our code
  more consistent.

Ruff works great with black, which I'd recommend we continue using.

Fixed a few minor issues that the new linter combo picked up.
2023-09-11 06:41:08 -04:00
Guido Petri
c97afeb327 Convert project to poetry (#6423)
* poetry init

* add all dependencies

* update pyproject.toml

* add poetry.lock

* remove requirements txt fixer

* add ldap3 group and make all groups optional

* remove requirements files

* convert dockerfile to use poetry instead of pip

* fix ldap 3 dependency group typo

* update ldap3 inline error

* update cypress to install only main poetry group

* convert test_all_deps

* update redis/rq

* don't create virtualenv

* add -dev suffix and make version snapshot work

* fix typo in snapshot commit message

* remove importlib-resources
2023-09-08 23:57:26 +00:00
Leandro Lomónaco
b1f738fc96 Excel and CSV Query Runner fix (#6439) 2023-09-08 18:07:06 -04:00
Eric Radman
6f6d203ca9 Remove importlib-resources from requirements (#6431)
This is not required for Python >= 3.8 since the goal of this project is
to back port functionality.  Also the version specified is not
compatible with later versions of Python.

https://pypi.org/project/importlib-resources/
2023-09-07 13:28:30 -04:00
Tomoki Sekiyama
36482f6717 Move from oauth2client to google-auth / support ADC (#6422)
oauth2client was deprecated in 2017 and is no longer maintained.
This rewrites the service credentials code and replaces it with google-auth.

It also makes the JSON key file optional for the data source and use the
application's default credentials (ADC) if the JSON key file is omitted.
This enables support for a variety of GCP authentication methods, including
the gcloud CLI, GCE metadata servers, and GKE or AWS Workload Identity.

Note that the bigquery_gce functionality is covered by ADC, but is retained
for compatibility reasons.

Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-09-07 23:50:18 +10:00
Eric Radman
3fee9f6cef Update pymssql and rapidjson (#6428)
Updates to these libraries are required for local development setup on Ubuntu 22 (python 3.10)
2023-09-06 18:58:06 -04:00
Vlad Gramuzov
cb4af6dd57 Add Tinybird query runner (#5616)
"

Co-authored-by: Thomas Rausch <thomas@thrau.at>
2023-09-06 12:59:08 +00:00
Avey777
7f42bf1b65 Add feature: Hide filter components on shared pages (#6115)
* Hide-filter-components-on-shared-pages

* Restyled by prettier

* Fix typo

---------

Co-authored-by: Jengro Woo <Jengro777@outlook.com>
Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: Justin Clift <justin@postgresql.org>
2023-09-06 22:51:48 +10:00
Justin Clift
41495ba940 Update rq related dependencies, to hopefully alleviate issue 6424 (#6426) 2023-09-06 18:37:49 +10:00
Wataru Kurashima
9b18e1805c ci: use docker/build-push-action (#6425) 2023-09-06 17:52:38 +10:00
Anirudh Bagri
c2e7df098d Add webex as destination (#5574)
* Add webex as destination

* import from destinations explicitly

* make format

* remove unattributed image

* make webex bot token required

* don't use magic string

* add metadata kwarg

* simplify link creation

* simplify alert description

* simplify alert subject

* split attachments template into method

* DRY message posting

* use api endpoint method

* add missing param to post_message

* static method

* static method attachments template

* log exception if send fails

* simplify destination handling

* transparent image / right size webex logo

* remove unused organization param

* rename api endpoint and make it a property

* add test

---------

Co-authored-by: Justin Clift <justin@postgresql.org>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-09-05 00:26:21 +00:00
Omer Lachish
f1d5ac0d58 Convert unexpected parameter validation errors to InvalidParameterError (#4084)
* convert unexpected validation errors to InvalidParameterError

* get rid of local except blocks - any failure to validate is caught in _valid()

* avoid bare exception

* don't assign exception if we're not going to use it

* preferentially raise querydetachedfromdatasource error if it is present

---------

Co-authored-by: Omer Lachish <omer@rauchy.net>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2023-09-04 21:08:41 +00:00
Justin Clift
710dd8c51b Generate docker hub preview image (#6420)
This needs the appropriate Docker Hub login credentials set in our GitHub settings before it will work properly.

  * DOCKER_USER environment variable
  * DOCKER_PASS secret
2023-09-04 04:30:24 +10:00
Justin Clift
2a2c90a014 Add pkg-config to the Docker image (#6419)
Without pkg-config the MySQL client install doesn't always work
2023-09-03 07:10:54 +00:00
390 changed files with 21768 additions and 7994 deletions

View File

@@ -1,11 +1,11 @@
FROM cypress/browsers:node16.18.0-chrome90-ff88 FROM cypress/browsers:node18.12.0-chrome106-ff106
ENV APP /usr/src/app ENV APP /usr/src/app
WORKDIR $APP WORKDIR $APP
COPY package.json yarn.lock .yarnrc $APP/ COPY package.json yarn.lock .yarnrc $APP/
COPY viz-lib $APP/viz-lib COPY viz-lib $APP/viz-lib
RUN npm install yarn@1.22.19 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null RUN npm install yarn@1.22.22 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null
COPY . $APP COPY . $APP

View File

@@ -1,4 +1,3 @@
version: '2.2'
services: services:
redash: redash:
build: ../ build: ../
@@ -19,7 +18,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: pgautoupgrade/pgautoupgrade:15-alpine3.8 image: postgres:18-alpine
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -1,10 +1,8 @@
version: "2.2"
x-redash-service: &redash-service x-redash-service: &redash-service
build: build:
context: ../ context: ../
args: args:
skip_dev_deps: "true" install_groups: "main"
skip_ds_deps: "true"
code_coverage: ${CODE_COVERAGE} code_coverage: ${CODE_COVERAGE}
x-redash-environment: &redash-environment x-redash-environment: &redash-environment
REDASH_LOG_LEVEL: "INFO" REDASH_LOG_LEVEL: "INFO"
@@ -68,7 +66,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: pgautoupgrade/pgautoupgrade:15-alpine3.8 image: postgres:18-alpine
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -1,21 +1,39 @@
#!/bin/bash #!/bin/bash
# This script only needs to run on the main Redash repo
if [ "${GITHUB_REPOSITORY}" != "getredash/redash" ]; then
echo "Skipping image build for Docker Hub, as this isn't the main Redash repository"
exit 0
fi
if [ "${GITHUB_REF_NAME}" != "master" ] && [ "${GITHUB_REF_NAME}" != "preview-image" ]; then
echo "Skipping image build for Docker Hub, as this isn't the 'master' nor 'preview-image' branch"
exit 0
fi
if [ "x${DOCKER_USER}" = "x" ] || [ "x${DOCKER_PASS}" = "x" ]; then
echo "Skipping image build for Docker Hub, as the login details aren't available"
exit 0
fi
set -e set -e
VERSION=$(jq -r .version package.json) VERSION=$(jq -r .version package.json)
VERSION_TAG=$VERSION.b$CIRCLE_BUILD_NUM VERSION_TAG="$VERSION.b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}"
export DOCKER_BUILDKIT=1 export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1 export COMPOSE_DOCKER_CLI_BUILD=1
docker login -u $DOCKER_USER -p $DOCKER_PASS docker login -u "${DOCKER_USER}" -p "${DOCKER_PASS}"
if [ $CIRCLE_BRANCH = master ] || [ $CIRCLE_BRANCH = preview-image ] DOCKERHUB_REPO="redash/redash"
then DOCKER_TAGS="-t redash/redash:preview -t redash/preview:${VERSION_TAG}"
docker build --build-arg skip_dev_deps=true -t redash/redash:preview -t redash/preview:$VERSION_TAG .
docker push redash/redash:preview
docker push redash/preview:$VERSION_TAG
else
docker build --build-arg skip_dev_deps=true -t redash/redash:$VERSION_TAG .
docker push redash/redash:$VERSION_TAG
fi
echo "Built: $VERSION_TAG" # Build the docker container
docker build --build-arg install_groups="main,all_ds,dev" ${DOCKER_TAGS} .
# Push the container to the preview build locations
docker push "${DOCKERHUB_REPO}:preview"
docker push "redash/preview:${VERSION_TAG}"
echo "Built: ${VERSION_TAG}"

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
VERSION=$(jq -r .version package.json) VERSION=$(jq -r .version package.json)
FULL_VERSION=$VERSION+b$CIRCLE_BUILD_NUM FULL_VERSION=${VERSION}+b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}
sed -ri "s/^__version__ = '([A-Za-z0-9.-]*)'/__version__ = '$FULL_VERSION'/" redash/__init__.py sed -ri "s/^__version__ = '([A-Za-z0-9.-]*)'/__version__ = '${FULL_VERSION}'/" redash/__init__.py
sed -i "s/dev/$CIRCLE_SHA1/" client/app/version.json sed -i "s/dev/${GITHUB_SHA}/" client/app/version.json

View File

@@ -1,5 +1,4 @@
client/.tmp/ client/.tmp/
client/dist/
node_modules/ node_modules/
viz-lib/node_modules/ viz-lib/node_modules/
.tmp/ .tmp/

View File

@@ -4,61 +4,73 @@ on:
branches: branches:
- master - master
pull_request: pull_request:
branches:
- master
env: env:
NODE_VERSION: 16.20.1 NODE_VERSION: 18
YARN_VERSION: 1.22.22
jobs: jobs:
backend-lint: backend-lint:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- uses: actions/setup-python@v4 ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-python@v5
with: with:
python-version: '3.8' python-version: '3.8'
- run: sudo pip install flake8==6.1.0 black==23.1.0 isort==5.12.0 - run: sudo pip install black==23.1.0 ruff==0.0.287
- run: flake8 . - run: ruff check .
- run: black --check . - run: black --check .
- run: isort --check-only --diff .
backend-unit-tests: backend-unit-tests:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: backend-lint needs: backend-lint
env: env:
COMPOSE_FILE: .ci/docker-compose.ci.yml COMPOSE_FILE: .ci/compose.ci.yaml
COMPOSE_PROJECT_NAME: redash COMPOSE_PROJECT_NAME: redash
COMPOSE_DOCKER_CLI_BUILD: 1 COMPOSE_DOCKER_CLI_BUILD: 1
DOCKER_BUILDKIT: 1 DOCKER_BUILDKIT: 1
steps: steps:
- uses: actions/checkout@v3 - if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- name: Build Docker Images - name: Build Docker Images
run: | run: |
set -x set -x
docker-compose build --build-arg test_all_deps=true --build-arg skip_frontend_build=true docker compose build --build-arg install_groups="main,all_ds,dev" --build-arg skip_frontend_build=true
docker-compose up -d docker compose up -d
sleep 10 sleep 10
- name: Create Test Database - name: Create Test Database
run: docker-compose -p redash run --rm postgres psql -h postgres -U postgres -c "create database tests;" run: docker compose -p redash run --rm postgres psql -h postgres -U postgres -c "create database tests;"
- name: List Enabled Query Runners - name: List Enabled Query Runners
run: docker-compose -p redash run --rm redash manage ds list_types run: docker compose -p redash run --rm redash manage ds list_types
- name: Run Tests - name: Run Tests
run: docker-compose -p redash run --name tests redash tests --junitxml=junit.xml --cov-report=xml --cov=redash --cov-config=.coveragerc tests/ run: docker compose -p redash run --name tests redash tests --junitxml=junit.xml --cov-report=xml --cov=redash --cov-config=.coveragerc tests/
- name: Copy Test Results - name: Copy Test Results
run: | run: |
mkdir -p /tmp/test-results/unit-tests mkdir -p /tmp/test-results/unit-tests
docker cp tests:/app/coverage.xml ./coverage.xml docker cp tests:/app/coverage.xml ./coverage.xml
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
- name: Upload coverage reports to Codecov # - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3 # uses: codecov/codecov-action@v3
# with:
# token: ${{ secrets.CODECOV_TOKEN }}
- name: Store Test Results - name: Store Test Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: test-results name: backend-test-results
path: /tmp/test-results path: /tmp/test-results
- name: Store Coverage Results - name: Store Coverage Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: coverage name: coverage
path: coverage.xml path: coverage.xml
@@ -66,39 +78,47 @@ jobs:
frontend-lint: frontend-lint:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- uses: actions/setup-node@v3 ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'yarn' cache: 'yarn'
- name: Install Dependencies - name: Install Dependencies
run: | run: |
npm install --global --force yarn@1.22.19 npm install --global --force yarn@$YARN_VERSION
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Run Lint - name: Run Lint
run: yarn lint:ci run: yarn lint:ci
- name: Store Test Results - name: Store Test Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: test-results name: frontend-test-results
path: /tmp/test-results path: /tmp/test-results
frontend-unit-tests: frontend-unit-tests:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: frontend-lint needs: frontend-lint
steps: steps:
- uses: actions/checkout@v3 - if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- uses: actions/setup-node@v3 ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'yarn' cache: 'yarn'
- name: Install Dependencies - name: Install Dependencies
run: | run: |
npm install --global --force yarn@1.22.19 npm install --global --force yarn@$YARN_VERSION
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Run App Tests - name: Run App Tests
run: yarn test run: yarn test
@@ -110,44 +130,48 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: frontend-lint needs: frontend-lint
env: env:
COMPOSE_FILE: .ci/docker-compose.cypress.yml COMPOSE_FILE: .ci/compose.cypress.yaml
COMPOSE_PROJECT_NAME: cypress COMPOSE_PROJECT_NAME: cypress
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
CYPRESS_INSTALL_BINARY: 0 CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
# PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
# CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
steps: steps:
- uses: actions/checkout@v3 - if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- uses: actions/setup-node@v3 ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'yarn' cache: 'yarn'
- name: Enable Code Coverage Report For Master Branch - name: Enable Code Coverage Report For Master Branch
if: endsWith(github.ref, '/master') if: endsWith(github.ref, '/master')
run: | run: |
echo "CODE_COVERAGE=true" >> $GITHUB_ENV echo "CODE_COVERAGE=true" >> "$GITHUB_ENV"
- name: Install Dependencies - name: Install Dependencies
run: | run: |
npm install --global --force yarn@1.22.19 npm install --global --force yarn@$YARN_VERSION
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Setup Redash Server - name: Setup Redash Server
run: | run: |
set -x set -x
yarn cypress build yarn cypress build
yarn cypress start -- --skip-db-seed yarn cypress start -- --skip-db-seed
docker-compose run cypress yarn cypress db-seed docker compose run cypress yarn cypress db-seed
- name: Execute Cypress Tests - name: Execute Cypress Tests
run: yarn cypress run-ci run: yarn cypress run-ci
- name: "Failure: output container logs to console" - name: "Failure: output container logs to console"
if: failure() if: failure()
run: docker-compose logs run: docker compose logs
- name: Copy Code Coverage Results - name: Copy Code Coverage Results
run: docker cp cypress:/usr/src/app/coverage ./coverage || true run: docker cp cypress:/usr/src/app/coverage ./coverage || true
- name: Store Coverage Results - name: Store Coverage Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: coverage name: coverage
path: coverage path: coverage

View File

@@ -1,26 +1,86 @@
name: Periodic Snapshot name: Periodic Snapshot
# 10 minutes after midnight on the first of every month
on: on:
schedule: schedule:
- cron: "10 0 1 * *" - cron: '10 0 1 * *' # 10 minutes after midnight on the first day of every month
workflow_dispatch:
inputs:
bump:
description: 'Bump the last digit of the version'
required: false
type: boolean
version:
description: 'Specific version to set'
required: false
default: ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions: permissions:
actions: write
contents: write contents: write
jobs: jobs:
bump-version-and-tag: bump-version-and-tag:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref_name == github.event.repository.default_branch
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.ACTION_PUSH_KEY }}
- run: | - run: |
date="$(date +%y.%m).0-dev" git config user.name 'github-actions[bot]'
gawk -i inplace -F: -v q=\" -v tag=$date '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
gawk -i inplace -F= -v q=\" -v tag=$date '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py
git config user.name github-actions # Function to bump the version
git config user.email github-actions@github.com bump_version() {
git add package.json redash/__init__.py local version="$1"
git commit -m "Shapshot: ${date}" local IFS=.
git push origin read -r major minor patch <<< "$version"
git tag $date patch=$((patch + 1))
git push origin $date echo "$major.$minor.$patch-dev"
}
# Determine the new version tag
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BUMP_INPUT="${{ github.event.inputs.bump }}"
SPECIFIC_VERSION="${{ github.event.inputs.version }}"
# Check if both bump and specific version are provided
if [ "$BUMP_INPUT" = "true" ] && [ -n "$SPECIFIC_VERSION" ]; then
echo "::error::Error: Cannot specify both bump and specific version."
exit 1
fi
if [ -n "$SPECIFIC_VERSION" ]; then
TAG_NAME="$SPECIFIC_VERSION-dev"
elif [ "$BUMP_INPUT" = "true" ]; then
CURRENT_VERSION=$(grep '"version":' package.json | awk -F\" '{print $4}')
TAG_NAME=$(bump_version "$CURRENT_VERSION")
else
echo "No version bump or specific version provided for manual dispatch."
exit 1
fi
else
TAG_NAME="$(date +%y.%m).0-dev"
fi
echo "New version tag: $TAG_NAME"
# Update version in files
gawk -i inplace -F: -v q=\" -v tag=${TAG_NAME} '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json
gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py
gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^version =/ { print $1 FS, q tag q; next} { print }' pyproject.toml
git add package.json redash/__init__.py pyproject.toml
git commit -m "Snapshot: ${TAG_NAME}"
git tag ${TAG_NAME}
git push --atomic origin master refs/tags/${TAG_NAME}
# Run the 'preview-image' workflow if run this workflow manually
# For more information, please see the: https://docs.github.com/en/actions/security-guides/automatic-token-authentication
if [ "$BUMP_INPUT" = "true" ] || [ -n "$SPECIFIC_VERSION" ]; then
gh workflow run preview-image.yml --ref $TAG_NAME
fi

185
.github/workflows/preview-image.yml vendored Normal file
View File

@@ -0,0 +1,185 @@
name: Preview Image
on:
push:
tags:
- '*-dev'
workflow_dispatch:
inputs:
dockerRepository:
description: 'Docker repository'
required: true
default: 'preview'
type: choice
options:
- preview
- redash
env:
NODE_VERSION: 18
jobs:
build-skip-check:
runs-on: ubuntu-22.04
outputs:
skip: ${{ steps.skip-check.outputs.skip }}
steps:
- name: Skip?
id: skip-check
run: |
if [[ "${{ vars.DOCKER_USER }}" == '' ]]; then
echo 'Docker user is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
echo 'Docker password is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ vars.DOCKER_REPOSITORY }}" == '' ]]; then
echo 'Docker repository is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
else
echo 'Docker user and password are set and branch is `master`.'
echo 'Building + pushing `preview` image.'
echo skip=false >> "$GITHUB_OUTPUT"
fi
build-docker-image:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
arch:
- amd64
- arm64
include:
- arch: amd64
os: ubuntu-22.04
- arch: arm64
os: ubuntu-22.04-arm
outputs:
VERSION_TAG: ${{ steps.version.outputs.VERSION_TAG }}
needs:
- build-skip-check
if: needs.build-skip-check.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.push.after }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: |
npm install --global --force yarn@1.22.22
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Set version
id: version
run: |
set -x
.ci/update_version
VERSION_TAG=$(jq -r .version package.json)
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
- name: Build and push preview image to Docker Hub
id: build-preview
uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
with:
tags: |
${{ vars.DOCKER_REPOSITORY }}/redash
${{ vars.DOCKER_REPOSITORY }}/preview
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env:
DOCKER_CONTENT_TRUST: true
- name: Build and push release image to Docker Hub
id: build-release
uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
with:
tags: |
${{ vars.DOCKER_REPOSITORY }}/redash:${{ steps.version.outputs.VERSION_TAG }}
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=false,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env:
DOCKER_CONTENT_TRUST: true
- name: "Failure: output container logs to console"
if: failure()
run: docker compose logs
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
if [[ "${{ github.event.inputs.dockerRepository }}" == 'preview' || !github.event.workflow_run ]]; then
digest="${{ steps.build-preview.outputs.digest}}"
else
digest="${{ steps.build-release.outputs.digest}}"
fi
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
merge-docker-image:
runs-on: ubuntu-22.04
needs: build-docker-image
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Create and push manifest for the preview image
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:preview \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:preview@sha256:%s ' *)
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
- name: Create and push manifest for the release image
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)

36
.github/workflows/restyled.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Restyled
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
restyled:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: restyled-io/actions/setup@v4
- id: restyler
uses: restyled-io/actions/run@v4
with:
fail-on-differences: true
- if: |
!cancelled() &&
steps.restyler.outputs.success == 'true' &&
github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/create-pull-request@v6
with:
base: ${{ steps.restyler.outputs.restyled-base }}
branch: ${{ steps.restyler.outputs.restyled-head }}
title: ${{ steps.restyler.outputs.restyled-title }}
body: ${{ steps.restyler.outputs.restyled-body }}
labels: "restyled"
reviewers: ${{ github.event.pull_request.user.login }}
delete-branch: true

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ client/dist
_build _build
.vscode .vscode
.env .env
.tool-versions
dump.rdb dump.rdb

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict = true

2
.nvmrc
View File

@@ -1 +1 @@
v16.20.1 v18

View File

@@ -1,19 +1,10 @@
repos: repos:
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.1.0 rev: 23.1.0
hooks: hooks:
- id: black - id: black
language_version: python3 language_version: python3
- repo: https://github.com/pycqa/flake8 - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 6.1.0 rev: "v0.0.287"
hooks: hooks:
- id: flake8 - id: ruff
exclude: "migration/.*|.git|viz-lib|node_modules|migrations|bin/upgrade"
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: requirements-txt-fixer

View File

@@ -38,7 +38,9 @@ request_review: author
# #
# These can be used to tell other automation to avoid our PRs. # These can be used to tell other automation to avoid our PRs.
# #
labels: ["Skip CI"] labels:
- restyled
- "Skip CI"
# Labels to ignore # Labels to ignore
# #
@@ -50,13 +52,13 @@ labels: ["Skip CI"]
# Restylers to run, and how # Restylers to run, and how
restylers: restylers:
- name: black - name: black
image: restyled/restyler-black:v19.10b0 image: restyled/restyler-black:v24.4.2
include: include:
- redash - redash
- tests - tests
- migrations/versions - migrations/versions
- name: prettier - name: prettier
image: restyled/restyler-prettier:v1.19.1-2 image: restyled/restyler-prettier:v3.3.2-2
command: command:
- prettier - prettier
- --write - --write

View File

@@ -1,6 +1,6 @@
FROM node:16.20.1 as frontend-builder FROM node:18-bookworm AS frontend-builder
RUN npm install --global --force yarn@1.22.19 RUN npm install --global --force yarn@1.22.22
# Controls whether to build the frontend assets # Controls whether to build the frontend assets
ARG skip_frontend_build ARG skip_frontend_build
@@ -14,33 +14,39 @@ USER redash
WORKDIR /frontend WORKDIR /frontend
COPY --chown=redash package.json yarn.lock .yarnrc /frontend/ COPY --chown=redash package.json yarn.lock .yarnrc /frontend/
COPY --chown=redash viz-lib /frontend/viz-lib COPY --chown=redash viz-lib /frontend/viz-lib
COPY --chown=redash scripts /frontend/scripts
# Controls whether to instrument code for coverage information # Controls whether to instrument code for coverage information
ARG code_coverage ARG code_coverage
ENV BABEL_ENV=${code_coverage:+test} ENV BABEL_ENV=${code_coverage:+test}
# Avoid issues caused by lags in disk and network I/O speeds when working on top of QEMU emulation for multi-platform image building.
RUN yarn config set network-timeout 300000
RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn --frozen-lockfile --network-concurrency 1; fi RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn --frozen-lockfile --network-concurrency 1; fi
COPY --chown=redash client /frontend/client COPY --chown=redash client /frontend/client
COPY --chown=redash webpack.config.js /frontend/ COPY --chown=redash webpack.config.js /frontend/
RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi RUN <<EOF
if [ "x$skip_frontend_build" = "x" ]; then
yarn build
else
mkdir -p /frontend/client/dist
touch /frontend/client/dist/multi_org.html
touch /frontend/client/dist/index.html
fi
EOF
FROM python:3.8-slim-buster FROM python:3.10-slim-bookworm
EXPOSE 5000 EXPOSE 5000
# Controls whether to install extra dependencies needed for all data sources.
ARG skip_ds_deps
# Controls whether to install dev dependencies.
ARG skip_dev_deps
# Controls whether to install all dependencies for testing.
ARG test_all_deps
RUN useradd --create-home redash RUN useradd --create-home redash
# Ubuntu packages # Ubuntu packages
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
pkg-config \
curl \ curl \
gnupg \ gnupg \
build-essential \ build-essential \
@@ -48,6 +54,8 @@ RUN apt-get update && \
libffi-dev \ libffi-dev \
sudo \ sudo \
git-core \ git-core \
# Kerberos, needed for MS SQL Python driver to compile on arm64
libkrb5-dev \
# Postgres client # Postgres client
libpq-dev \ libpq-dev \
# ODBC support: # ODBC support:
@@ -67,41 +75,41 @@ RUN apt-get update && \
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG databricks_odbc_driver_url=https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc/2.6.26/SimbaSparkODBC-2.6.26.1045-Debian-64bit.zip ARG databricks_odbc_driver_url=https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc/2.6.26/SimbaSparkODBC-2.6.26.1045-Debian-64bit.zip
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ RUN <<EOF
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \ if [ "$TARGETPLATFORM" = "linux/amd64" ]; then
&& curl https://packages.microsoft.com/config/debian/10/prod.list > /etc/apt/sources.list.d/mssql-release.list \ curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
&& apt-get update \ curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list
&& ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql17 \ apt-get update
&& apt-get clean \ ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18
&& rm -rf /var/lib/apt/lists/* \ apt-get clean
&& curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip \ rm -rf /var/lib/apt/lists/*
&& chmod 600 /tmp/simba_odbc.zip \ curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip
&& unzip /tmp/simba_odbc.zip -d /tmp/simba \ chmod 600 /tmp/simba_odbc.zip
&& dpkg -i /tmp/simba/*.deb \ unzip /tmp/simba_odbc.zip -d /tmp/simba
&& printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini \ dpkg -i /tmp/simba/*.deb
&& rm /tmp/simba_odbc.zip \ printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini
&& rm -rf /tmp/simba; fi rm /tmp/simba_odbc.zip
rm -rf /tmp/simba
fi
EOF
WORKDIR /app WORKDIR /app
# Disable PIP Cache and Version Check ENV POETRY_VERSION=2.1.4
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV POETRY_HOME=/etc/poetry
ENV PIP_NO_CACHE_DIR=1 ENV POETRY_VIRTUALENVS_CREATE=false
RUN curl -sSL https://install.python-poetry.org | python3 -
RUN pip install pip==23.1.2; # Avoid crashes, including corrupted cache artifacts, when building multi-platform images with GitHub Actions.
RUN /etc/poetry/bin/poetry cache clear pypi --all
# We first copy only the requirements file, to avoid rebuilding on every file change. COPY pyproject.toml poetry.lock ./
COPY requirements_all_ds.txt ./
RUN if [ "x$skip_ds_deps" = "x" ] ; then cat requirements_all_ds.txt | sed -e '/^\s*#.*$/d' -e '/^\s*$/d' | xargs -n 1 pip install || true ; else echo "Skipping pip install -r requirements_all_ds.txt" ; fi
ARG POETRY_OPTIONS="--no-root --no-interaction --no-ansi"
COPY requirements_dev.txt ./ # for LDAP authentication, install with `ldap3` group
RUN if [ "x$skip_dev_deps" = "x" ] ; then pip install -r requirements_dev.txt ; fi # disabled by default due to GPL license conflict
ARG install_groups="main,all_ds,dev"
COPY requirements.txt ./ RUN /etc/poetry/bin/poetry install --only $install_groups $POETRY_OPTIONS
RUN pip install -r requirements.txt
RUN if [ "x$test_all_deps" != "x" ] ; then pip3 install -r requirements.txt -r requirements_dev.txt -r requirements_all_ds.txt ; fi
COPY --chown=redash . /app COPY --chown=redash . /app
COPY --from=frontend-builder --chown=redash /frontend/client/dist /app/client/dist COPY --from=frontend-builder --chown=redash /frontend/client/dist /app/client/dist

View File

@@ -1,26 +1,39 @@
.PHONY: compose_build up test_db create_database clean down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash .PHONY: compose_build up test_db create_database clean down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash
compose_build: .env compose_build: .env
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose build COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
up: up:
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose up -d --build docker compose up -d redis postgres --remove-orphans
docker compose exec -u postgres postgres psql postgres --csv \
-1tqc "SELECT table_name FROM information_schema.tables WHERE table_name = 'organizations'" 2> /dev/null \
| grep -q "organizations" || make create_database
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build --remove-orphans
test_db: test_db:
@for i in `seq 1 5`; do \ @for i in `seq 1 5`; do \
if (docker-compose exec postgres sh -c 'psql -U postgres -c "select 1;"' 2>&1 > /dev/null) then break; \ if (docker compose exec postgres sh -c 'psql -U postgres -c "select 1;"' 2>&1 > /dev/null) then break; \
else echo "postgres initializing..."; sleep 5; fi \ else echo "postgres initializing..."; sleep 5; fi \
done done
docker-compose exec postgres sh -c 'psql -U postgres -c "drop database if exists tests;" && psql -U postgres -c "create database tests;"' docker compose exec postgres sh -c 'psql -U postgres -c "drop database if exists tests;" && psql -U postgres -c "create database tests;"'
create_database: .env create_database: .env
docker-compose run server create_db docker compose run server create_db
clean: clean:
docker-compose down && docker-compose rm docker compose down
docker compose --project-name cypress down
docker compose rm --stop --force
docker compose --project-name cypress rm --stop --force
docker image rm --force \
cypress-server:latest cypress-worker:latest cypress-scheduler:latest \
redash-server:latest redash-worker:latest redash-scheduler:latest
docker container prune --force
docker image prune --force
docker volume prune --force
down: down:
docker-compose down docker compose down
.env: .env:
printf "REDASH_COOKIE_SECRET=`pwgen -1s 32`\nREDASH_SECRET_KEY=`pwgen -1s 32`\n" >> .env printf "REDASH_COOKIE_SECRET=`pwgen -1s 32`\nREDASH_SECRET_KEY=`pwgen -1s 32`\n" >> .env
@@ -31,19 +44,20 @@ format:
pre-commit run --all-files pre-commit run --all-files
tests: tests:
docker-compose run server tests docker compose run server tests
lint: lint:
./bin/flake8_tests.sh ruff check .
black --check . --diff
backend-unit-tests: up test_db backend-unit-tests: up test_db
docker-compose run --rm --name tests server tests docker compose run --rm --name tests server tests
frontend-unit-tests: frontend-unit-tests:
CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 yarn --frozen-lockfile CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 yarn --frozen-lockfile
yarn test yarn test
test: lint backend-unit-tests frontend-unit-tests test: backend-unit-tests frontend-unit-tests lint
build: build:
yarn build yarn build
@@ -55,7 +69,7 @@ start:
yarn start yarn start
redis-cli: redis-cli:
docker-compose run --rm redis redis-cli -h redis docker compose run --rm redis redis-cli -h redis
bash: bash:
docker-compose run --rm server bash docker compose run --rm server bash

View File

@@ -3,7 +3,6 @@
</p> </p>
[![Documentation](https://img.shields.io/badge/docs-redash.io/help-brightgreen.svg)](https://redash.io/help/) [![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)
[![GitHub Build](https://github.com/getredash/redash/actions/workflows/ci.yml/badge.svg)](https://github.com/getredash/redash/actions) [![GitHub Build](https://github.com/getredash/redash/actions/workflows/ci.yml/badge.svg)](https://github.com/getredash/redash/actions)
Redash is designed to enable anyone, regardless of the level of technical sophistication, to harness the power of data big and small. SQL users leverage Redash to explore, query, visualize, and share data from any data sources. Their work in turn enables anybody in their organization to use the data. Every day, millions of users at thousands of organizations around the world use Redash to develop insights and make data-driven decisions. Redash is designed to enable anyone, regardless of the level of technical sophistication, to harness the power of data big and small. SQL users leverage Redash to explore, query, visualize, and share data from any data sources. Their work in turn enables anybody in their organization to use the data. Every day, millions of users at thousands of organizations around the world use Redash to develop insights and make data-driven decisions.
@@ -47,6 +46,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
- Dgraph - Dgraph
- Apache Drill - Apache Drill
- Apache Druid - Apache Druid
- e6data
- Eccenca Corporate Memory - Eccenca Corporate Memory
- Elasticsearch - Elasticsearch
- Exasol - Exasol
@@ -61,6 +61,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
- Apache Hive - Apache Hive
- Apache Impala - Apache Impala
- InfluxDB - InfluxDB
- InfluxDBv2
- IBM Netezza Performance Server - IBM Netezza Performance Server
- JIRA (JQL) - JIRA (JQL)
- JSON - JSON
@@ -83,6 +84,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
- Python - Python
- Qubole - Qubole
- Rockset - Rockset
- RisingWave
- Salesforce - Salesforce
- ScyllaDB - ScyllaDB
- Shell Scripts - Shell Scripts
@@ -90,6 +92,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
- SPARQL - SPARQL
- SQLite - SQLite
- TiDB - TiDB
- Tinybird
- TreasureData - TreasureData
- Trino - Trino
- Uptycs - Uptycs

View File

@@ -46,7 +46,7 @@ server() {
MAX_REQUESTS=${MAX_REQUESTS:-1000} MAX_REQUESTS=${MAX_REQUESTS:-1000}
MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100} MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100}
TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60} TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60}
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT --limit-request-line ${REDASH_GUNICORN_LIMIT_REQUEST_LINE:-0}
} }
create_db() { create_db() {
@@ -67,7 +67,7 @@ help() {
echo "" echo ""
echo "shell -- open shell" echo "shell -- open shell"
echo "dev_server -- start Flask development server with debugger and auto reload" echo "dev_server -- start Flask development server with debugger and auto reload"
echo "debug -- start Flask development server with remote debugger via ptvsd" echo "debug -- start Flask development server with remote debugger via debugpy"
echo "create_db -- create database tables" echo "create_db -- create database tables"
echo "manage -- CLI to manage redash" echo "manage -- CLI to manage redash"
echo "tests -- run tests" echo "tests -- run tests"

View File

@@ -1,9 +0,0 @@
#!/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=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,254 +0,0 @@
#!/usr/bin/env python3
import argparse
import os
import subprocess
import sys
import urllib
from collections import namedtuple
from fnmatch import fnmatch
import requests
try:
import semver
except ImportError:
print("Missing required library: semver.")
exit(1)
REDASH_HOME = os.environ.get("REDASH_HOME", "/opt/redash")
CURRENT_VERSION_PATH = "{}/current".format(REDASH_HOME)
def run(cmd, cwd=None):
if not cwd:
cwd = REDASH_HOME
return subprocess.check_output(cmd, cwd=cwd, shell=True, stderr=subprocess.STDOUT)
def confirm(question):
reply = str(input(question + " (y/n): ")).lower().strip()
if reply[0] == "y":
return True
if reply[0] == "n":
return False
else:
return confirm("Please use 'y' or 'n'")
def version_path(version_name):
return "{}/{}".format(REDASH_HOME, version_name)
END_CODE = "\033[0m"
def colored_string(text, color):
if sys.stdout.isatty():
return "{}{}{}".format(color, text, END_CODE)
else:
return text
def h1(text):
print(colored_string(text, "\033[4m\033[1m"))
def green(text):
print(colored_string(text, "\033[92m"))
def red(text):
print(colored_string(text, "\033[91m"))
class Release(namedtuple("Release", ("version", "download_url", "filename", "description"))):
def v1_or_newer(self):
return semver.compare(self.version, "1.0.0-alpha") >= 0
def is_newer(self, version):
return semver.compare(self.version, version) > 0
@property
def version_name(self):
return self.filename.replace(".tar.gz", "")
def get_latest_release_from_ci():
response = requests.get(
"https://circleci.com/api/v1.1/project/github/getredash/redash/latest/artifacts?branch=master"
)
if response.status_code != 200:
exit("Failed getting releases (status code: %s)." % response.status_code)
tarball_asset = filter(lambda asset: asset["url"].endswith(".tar.gz"), response.json())[0]
filename = urllib.unquote(tarball_asset["pretty_path"].split("/")[-1])
version = filename.replace("redash.", "").replace(".tar.gz", "")
release = Release(version, tarball_asset["url"], filename, "")
return release
def get_release(channel):
if channel == "ci":
return get_latest_release_from_ci()
response = requests.get("https://version.redash.io/api/releases?channel={}".format(channel))
release = response.json()[0]
filename = release["download_url"].split("/")[-1]
release = Release(release["version"], release["download_url"], filename, release["description"])
return release
def link_to_current(version_name):
green("Linking to current version...")
run("ln -nfs {} {}".format(version_path(version_name), CURRENT_VERSION_PATH))
def restart_services():
# We're doing this instead of simple 'supervisorctl restart all' because
# otherwise it won't notice that /opt/redash/current pointing at a different
# directory.
green("Restarting...")
try:
run("sudo /etc/init.d/redash_supervisord restart")
except subprocess.CalledProcessError as e:
run("sudo service supervisor restart")
def update_requirements(version_name):
green("Installing new Python packages (if needed)...")
new_requirements_file = "{}/requirements.txt".format(version_path(version_name))
install_requirements = False
try:
run("diff {}/requirements.txt {}".format(CURRENT_VERSION_PATH, new_requirements_file)) != 0
except subprocess.CalledProcessError as e:
if e.returncode != 0:
install_requirements = True
if install_requirements:
run("sudo pip install -r {}".format(new_requirements_file))
def apply_migrations(release):
green("Running migrations (if needed)...")
if not release.v1_or_newer():
return apply_migrations_pre_v1(release.version_name)
run("sudo -u redash bin/run ./manage.py db upgrade", cwd=version_path(release.version_name))
def find_migrations(version_name):
current_migrations = set(
[f for f in os.listdir("{}/migrations".format(CURRENT_VERSION_PATH)) if fnmatch(f, "*_*.py")]
)
new_migrations = sorted(
[f for f in os.listdir("{}/migrations".format(version_path(version_name))) if fnmatch(f, "*_*.py")]
)
return [m for m in new_migrations if m not in current_migrations]
def apply_migrations_pre_v1(version_name):
new_migrations = find_migrations(version_name)
if new_migrations:
green("New migrations to run: ")
print(", ".join(new_migrations))
else:
print("No new migrations in this version.")
if new_migrations and confirm("Apply new migrations? (make sure you have backup)"):
for migration in new_migrations:
print("Applying {}...".format(migration))
run(
"sudo sudo -u redash PYTHONPATH=. bin/run python migrations/{}".format(migration),
cwd=version_path(version_name),
)
def download_and_unpack(release):
directory_name = release.version_name
green("Downloading release tarball...")
run(
'sudo wget --header="Accept: application/octet-stream" -O {} {}'.format(release.filename, release.download_url)
)
green("Unpacking to: {}...".format(directory_name))
run("sudo mkdir -p {}".format(directory_name))
run("sudo tar -C {} -xvf {}".format(directory_name, release.filename))
green("Changing ownership to redash...")
run("sudo chown redash {}".format(directory_name))
green("Linking .env file...")
run("sudo ln -nfs {}/.env {}/.env".format(REDASH_HOME, version_path(directory_name)))
def current_version():
real_current_path = os.path.realpath(CURRENT_VERSION_PATH).replace(".b", "+b")
return real_current_path.replace(REDASH_HOME + "/", "").replace("redash.", "")
def verify_minimum_version():
green("Current version: " + current_version())
if semver.compare(current_version(), "0.12.0") < 0:
red("You need to have Redash v0.12.0 or newer to upgrade to post v1.0.0 releases.")
green("To upgrade to v0.12.0, run the upgrade script set to the legacy channel (--channel legacy).")
exit(1)
def show_description_and_confirm(description):
if description:
print(description)
if not confirm("Continue with upgrade?"):
red("Cancelling upgrade.")
exit(1)
def verify_newer_version(release):
if not release.is_newer(current_version()):
red("The found release is not newer than your current deployed release ({}).".format(current_version()))
if not confirm("Continue with upgrade?"):
red("Cancelling upgrade.")
exit(1)
def deploy_release(channel):
h1("Starting Redash upgrade:")
release = get_release(channel)
green("Found version: {}".format(release.version))
if release.v1_or_newer():
verify_minimum_version()
verify_newer_version(release)
show_description_and_confirm(release.description)
try:
download_and_unpack(release)
update_requirements(release.version_name)
apply_migrations(release)
link_to_current(release.version_name)
restart_services()
green("Done! Enjoy.")
except subprocess.CalledProcessError as e:
red("Failed running: {}".format(e.cmd))
red("Exit status: {}\nOutput:\n{}".format(e.returncode, e.output))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--channel", help="The channel to get release from (default: stable).", default="stable")
args = parser.parse_args()
deploy_release(args.channel)

View File

@@ -15,7 +15,7 @@ body {
display: table; display: table;
width: 100%; width: 100%;
padding: 10px; padding: 10px;
height: calc(100vh - 116px); height: calc(100% - 116px);
} }
@media (min-width: 992px) { @media (min-width: 992px) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -20,7 +20,7 @@ html {
html, html,
body { body {
min-height: 100vh; height: 100%;
} }
body { body {
@@ -35,7 +35,7 @@ body {
} }
#application-root { #application-root {
min-height: 100vh; height: 100%;
} }
#application-root, #application-root,

View File

@@ -10,7 +10,7 @@
vertical-align: middle; vertical-align: middle;
display: inline-block; display: inline-block;
width: 1px; width: 1px;
height: 100vh; height: 100%;
} }
} }

View File

@@ -8,7 +8,7 @@ body.fixed-layout {
padding-bottom: 0; padding-bottom: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100%;
.application-layout-content > div { .application-layout-content > div {
display: flex; display: flex;
@@ -90,7 +90,7 @@ body.fixed-layout {
.embed__vis { .embed__vis {
display: flex; display: flex;
flex-flow: column; flex-flow: column;
height: calc(~'100vh - 25px'); height: calc(~'100% - 25px');
> .embed-heading { > .embed-heading {
flex: 0 0 auto; flex: 0 0 auto;
@@ -223,6 +223,7 @@ body.fixed-layout {
} }
.editor__left__schema { .editor__left__schema {
min-height: 120px;
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -7,10 +7,10 @@ body #application-root {
flex-direction: row; flex-direction: row;
justify-content: stretch; justify-content: stretch;
padding-bottom: 0 !important; padding-bottom: 0 !important;
height: 100vh; height: 100%;
.application-layout-side-menu { .application-layout-side-menu {
height: 100vh; height: 100%;
position: relative; position: relative;
@media @mobileBreakpoint { @media @mobileBreakpoint {
@@ -47,6 +47,10 @@ body #application-root {
} }
} }
body > section {
height: 100%;
}
body.fixed-layout #application-root { body.fixed-layout #application-root {
.application-layout-content { .application-layout-content {
padding-bottom: 0; padding-bottom: 0;

View File

@@ -22,7 +22,7 @@ function BeaconConsent() {
setHide(true); setHide(true);
}; };
const confirmConsent = confirm => { const confirmConsent = (confirm) => {
let message = "🙏 Thank you."; let message = "🙏 Thank you.";
if (!confirm) { if (!confirm) {
@@ -47,7 +47,8 @@ function BeaconConsent() {
<HelpTrigger type="USAGE_DATA_SHARING" /> <HelpTrigger type="USAGE_DATA_SHARING" />
</> </>
} }
bordered={false}> bordered={false}
>
<Text>Help Redash improve by automatically sending anonymous usage data:</Text> <Text>Help Redash improve by automatically sending anonymous usage data:</Text>
<div className="m-t-5"> <div className="m-t-5">
<ul> <ul>
@@ -66,8 +67,7 @@ function BeaconConsent() {
</div> </div>
<div className="m-t-15"> <div className="m-t-15">
<Text type="secondary"> <Text type="secondary">
You can change this setting anytime from the{" "} You can change this setting anytime from the <Link href="settings/general">Settings</Link> page.
<Link href="settings/organization">Organization Settings</Link> page.
</Text> </Text>
</div> </div>
</Card> </Card>

View File

@@ -12,6 +12,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector"; import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import { useUniqueId } from "@/lib/hooks/useUniqueId"; import { useUniqueId } from "@/lib/hooks/useUniqueId";
import "./EditParameterSettingsDialog.less";
const { Option } = Select; const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } }; const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
@@ -26,7 +27,7 @@ function isTypeDateRange(type) {
function joinExampleList(multiValuesOptions) { function joinExampleList(multiValuesOptions) {
const { prefix, suffix } = 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 }) { function NameInput({ name, type, onChange, existingNames, setValidation }) {
@@ -54,7 +55,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) {
return ( return (
<Form.Item required label="Keyword" help={helpText} validateStatus={validateStatus} {...formItemProps}> <Form.Item required label="Keyword" help={helpText} validateStatus={validateStatus} {...formItemProps}>
<Input onChange={e => onChange(e.target.value)} autoFocus /> <Input onChange={(e) => onChange(e.target.value)} autoFocus />
</Form.Item> </Form.Item>
); );
} }
@@ -71,6 +72,8 @@ function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter)); const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true); const [isNameValid, setIsNameValid] = useState(true);
const [initialQuery, setInitialQuery] = useState(); const [initialQuery, setInitialQuery] = useState();
const [userInput, setUserInput] = useState(param.regex || "");
const [isValidRegex, setIsValidRegex] = useState(true);
const isNew = !props.parameter.name; const isNew = !props.parameter.name;
@@ -114,6 +117,17 @@ function EditParameterSettingsDialog(props) {
const paramFormId = useUniqueId("paramForm"); const paramFormId = useUniqueId("paramForm");
const handleRegexChange = (e) => {
setUserInput(e.target.value);
try {
new RegExp(e.target.value);
setParam({ ...param, regex: e.target.value });
setIsValidRegex(true);
} catch (error) {
setIsValidRegex(false);
}
};
return ( return (
<Modal <Modal
{...props.dialog.props} {...props.dialog.props}
@@ -129,15 +143,17 @@ function EditParameterSettingsDialog(props) {
disabled={!isFulfilled()} disabled={!isFulfilled()}
type="primary" type="primary"
form={paramFormId} form={paramFormId}
data-test="SaveParameterSettings"> data-test="SaveParameterSettings"
>
{isNew ? "Add Parameter" : "OK"} {isNew ? "Add Parameter" : "OK"}
</Button>, </Button>,
]}> ]}
>
<Form layout="horizontal" onFinish={onConfirm} id={paramFormId}> <Form layout="horizontal" onFinish={onConfirm} id={paramFormId}>
{isNew && ( {isNew && (
<NameInput <NameInput
name={param.name} name={param.name}
onChange={name => setParam({ ...param, name })} onChange={(name) => setParam({ ...param, name })}
setValidation={setIsNameValid} setValidation={setIsNameValid}
existingNames={props.existingParams} existingNames={props.existingParams}
type={param.type} type={param.type}
@@ -146,15 +162,16 @@ function EditParameterSettingsDialog(props) {
<Form.Item required label="Title" {...formItemProps}> <Form.Item required label="Title" {...formItemProps}>
<Input <Input
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title} value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
onChange={e => setParam({ ...param, title: e.target.value })} onChange={(e) => setParam({ ...param, title: e.target.value })}
data-test="ParameterTitleInput" data-test="ParameterTitleInput"
/> />
</Form.Item> </Form.Item>
<Form.Item label="Type" {...formItemProps}> <Form.Item label="Type" {...formItemProps}>
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect"> <Select value={param.type} onChange={(type) => setParam({ ...param, type })} data-test="ParameterTypeSelect">
<Option value="text" data-test="TextParameterTypeOption"> <Option value="text" data-test="TextParameterTypeOption">
Text Text
</Option> </Option>
<Option value="text-pattern">Text Pattern</Option>
<Option value="number" data-test="NumberParameterTypeOption"> <Option value="number" data-test="NumberParameterTypeOption">
Number Number
</Option> </Option>
@@ -180,12 +197,26 @@ function EditParameterSettingsDialog(props) {
<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> </Select>
</Form.Item> </Form.Item>
{param.type === "text-pattern" && (
<Form.Item
label="Regex"
help={!isValidRegex ? "Invalid Regex Pattern" : "Valid Regex Pattern"}
{...formItemProps}
>
<Input
value={userInput}
onChange={handleRegexChange}
className={!isValidRegex ? "input-error" : ""}
data-test="RegexPatternInput"
/>
</Form.Item>
)}
{param.type === "enum" && ( {param.type === "enum" && (
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}> <Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
<Input.TextArea <Input.TextArea
rows={3} rows={3}
value={param.enumOptions} value={param.enumOptions}
onChange={e => setParam({ ...param, enumOptions: e.target.value })} onChange={(e) => setParam({ ...param, enumOptions: e.target.value })}
/> />
</Form.Item> </Form.Item>
)} )}
@@ -193,7 +224,7 @@ function EditParameterSettingsDialog(props) {
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}> <Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
<QuerySelector <QuerySelector
selectedQuery={initialQuery} selectedQuery={initialQuery}
onChange={q => setParam({ ...param, queryId: q && q.id })} onChange={(q) => setParam({ ...param, queryId: q && q.id })}
type="select" type="select"
/> />
</Form.Item> </Form.Item>
@@ -202,7 +233,7 @@ function EditParameterSettingsDialog(props) {
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}> <Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
<Checkbox <Checkbox
defaultChecked={!!param.multiValuesOptions} defaultChecked={!!param.multiValuesOptions}
onChange={e => onChange={(e) =>
setParam({ setParam({
...param, ...param,
multiValuesOptions: e.target.checked multiValuesOptions: e.target.checked
@@ -214,7 +245,8 @@ function EditParameterSettingsDialog(props) {
: null, : null,
}) })
} }
data-test="AllowMultipleValuesCheckbox"> data-test="AllowMultipleValuesCheckbox"
>
Allow multiple values Allow multiple values
</Checkbox> </Checkbox>
</Form.Item> </Form.Item>
@@ -227,10 +259,11 @@ function EditParameterSettingsDialog(props) {
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code> Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
</React.Fragment> </React.Fragment>
} }
{...formItemProps}> {...formItemProps}
>
<Select <Select
value={param.multiValuesOptions.prefix} value={param.multiValuesOptions.prefix}
onChange={quoteOption => onChange={(quoteOption) =>
setParam({ setParam({
...param, ...param,
multiValuesOptions: { multiValuesOptions: {
@@ -240,7 +273,8 @@ function EditParameterSettingsDialog(props) {
}, },
}) })
} }
data-test="QuotationSelect"> data-test="QuotationSelect"
>
<Option value="">None (default)</Option> <Option value="">None (default)</Option>
<Option value="'">Single Quotation Mark</Option> <Option value="'">Single Quotation Mark</Option>
<Option value={'"'} data-test="DoubleQuotationMarkOption"> <Option value={'"'} data-test="DoubleQuotationMarkOption">

View File

@@ -0,0 +1,3 @@
.input-error {
border-color: red !important;
}

View File

@@ -101,7 +101,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
clearTimeout(this.iframeLoadingTimeout); clearTimeout(this.iframeLoadingTimeout);
} }
loadIframe = url => { loadIframe = (url) => {
clearTimeout(this.iframeLoadingTimeout); clearTimeout(this.iframeLoadingTimeout);
this.setState({ loading: true, error: false }); this.setState({ loading: true, error: false });
@@ -116,8 +116,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
clearTimeout(this.iframeLoadingTimeout); clearTimeout(this.iframeLoadingTimeout);
}; };
onPostMessageReceived = event => { onPostMessageReceived = (event) => {
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) { if (!some(allowedDomains, (domain) => startsWith(event.origin, domain))) {
return; return;
} }
@@ -134,7 +134,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
return helpTriggerType ? helpTriggerType[0] : this.props.href; return helpTriggerType ? helpTriggerType[0] : this.props.href;
}; };
openDrawer = e => { openDrawer = (e) => {
// keep "open in new tab" behavior // keep "open in new tab" behavior
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault(); e.preventDefault();
@@ -144,7 +144,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
} }
}; };
closeDrawer = event => { closeDrawer = (event) => {
if (event) { if (event) {
event.preventDefault(); event.preventDefault();
} }
@@ -161,7 +161,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
const tooltip = get(types, `${this.props.type}[1]`, this.props.title); const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
const className = cx("help-trigger", this.props.className); const className = cx("help-trigger", this.props.className);
const url = this.state.currentUrl; const url = this.state.currentUrl;
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain)); const isAllowedDomain = some(allowedDomains, (domain) => startsWith(url || targetUrl, domain));
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain; const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
return ( return (
@@ -180,13 +180,15 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
)} )}
</> </>
) : null ) : null
}> }
>
<Link <Link
href={url || this.getUrl()} href={url || this.getUrl()}
className={className} className={className}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}> onClick={shouldRenderAsLink ? () => {} : this.openDrawer}
>
{this.props.children} {this.props.children}
</Link> </Link>
</Tooltip> </Tooltip>
@@ -197,7 +199,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
visible={this.state.visible} visible={this.state.visible}
className={cx("help-drawer", drawerClassName)} className={cx("help-drawer", drawerClassName)}
destroyOnClose destroyOnClose
width={400}> width={400}
>
<div className="drawer-wrapper"> <div className="drawer-wrapper">
<div className="drawer-menu"> <div className="drawer-menu">
{url && ( {url && (

View File

@@ -33,10 +33,10 @@ export const MappingType = {
}; };
export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) { export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) {
return map(mappings, mapping => { return map(mappings, (mapping) => {
const result = extend({}, mapping); const result = extend({}, mapping);
const alreadyExists = includes(existingParameterNames, mapping.mapTo); const alreadyExists = includes(existingParameterNames, mapping.mapTo);
result.param = find(parameters, p => p.name === mapping.name); result.param = find(parameters, (p) => p.name === mapping.name);
switch (mapping.type) { switch (mapping.type) {
case ParameterMappingType.DashboardLevel: case ParameterMappingType.DashboardLevel:
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew; result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
@@ -62,7 +62,7 @@ export function editableMappingsToParameterMappings(mappings) {
map( map(
// convert to map // convert to map
mappings, mappings,
mapping => { (mapping) => {
const result = extend({}, mapping); const result = extend({}, mapping);
switch (mapping.type) { switch (mapping.type) {
case MappingType.DashboardAddNew: case MappingType.DashboardAddNew:
@@ -95,11 +95,11 @@ export function editableMappingsToParameterMappings(mappings) {
export function synchronizeWidgetTitles(sourceMappings, widgets) { export function synchronizeWidgetTitles(sourceMappings, widgets) {
const affectedWidgets = []; const affectedWidgets = [];
each(sourceMappings, sourceMapping => { each(sourceMappings, (sourceMapping) => {
if (sourceMapping.type === ParameterMappingType.DashboardLevel) { if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
each(widgets, widget => { each(widgets, (widget) => {
const widgetMappings = widget.options.parameterMappings; const widgetMappings = widget.options.parameterMappings;
each(widgetMappings, widgetMapping => { each(widgetMappings, (widgetMapping) => {
// check if mapped to the same dashboard-level parameter // check if mapped to the same dashboard-level parameter
if ( if (
widgetMapping.type === ParameterMappingType.DashboardLevel && widgetMapping.type === ParameterMappingType.DashboardLevel &&
@@ -140,7 +140,7 @@ export class ParameterMappingInput extends React.Component {
className: "form-item", className: "form-item",
}; };
updateSourceType = type => { updateSourceType = (type) => {
let { let {
mapping: { mapTo }, mapping: { mapTo },
} = this.props; } = this.props;
@@ -155,7 +155,7 @@ export class ParameterMappingInput extends React.Component {
this.updateParamMapping({ type, mapTo }); this.updateParamMapping({ type, mapTo });
}; };
updateParamMapping = update => { updateParamMapping = (update) => {
const { onChange, mapping } = this.props; const { onChange, mapping } = this.props;
const newMapping = extend({}, mapping, update); const newMapping = extend({}, mapping, update);
if (newMapping.value !== mapping.value) { if (newMapping.value !== mapping.value) {
@@ -175,7 +175,7 @@ export class ParameterMappingInput extends React.Component {
renderMappingTypeSelector() { renderMappingTypeSelector() {
const noExisting = isEmpty(this.props.existingParamNames); const noExisting = isEmpty(this.props.existingParamNames);
return ( return (
<Radio.Group value={this.props.mapping.type} onChange={e => this.updateSourceType(e.target.value)}> <Radio.Group value={this.props.mapping.type} onChange={(e) => this.updateSourceType(e.target.value)}>
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption"> <Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
New dashboard parameter New dashboard parameter
</Radio> </Radio>
@@ -205,16 +205,16 @@ export class ParameterMappingInput extends React.Component {
<Input <Input
value={mapTo} value={mapTo}
aria-label="Parameter name (key)" aria-label="Parameter name (key)"
onChange={e => this.updateParamMapping({ mapTo: e.target.value })} onChange={(e) => this.updateParamMapping({ mapTo: e.target.value })}
/> />
); );
} }
renderDashboardMapToExisting() { renderDashboardMapToExisting() {
const { mapping, existingParamNames } = this.props; const { mapping, existingParamNames } = this.props;
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName })); const options = map(existingParamNames, (paramName) => ({ label: paramName, value: paramName }));
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />; return <Select value={mapping.mapTo} onChange={(mapTo) => this.updateParamMapping({ mapTo })} options={options} />;
} }
renderStaticValue() { renderStaticValue() {
@@ -226,7 +226,8 @@ export class ParameterMappingInput extends React.Component {
enumOptions={mapping.param.enumOptions} enumOptions={mapping.param.enumOptions}
queryId={mapping.param.queryId} queryId={mapping.param.queryId}
parameter={mapping.param} parameter={mapping.param}
onSelect={value => this.updateParamMapping({ value })} onSelect={(value) => this.updateParamMapping({ value })}
regex={mapping.param.regex}
/> />
); );
} }
@@ -284,12 +285,12 @@ class MappingEditor extends React.Component {
}; };
} }
onVisibleChange = visible => { onVisibleChange = (visible) => {
if (visible) this.show(); if (visible) this.show();
else this.hide(); else this.hide();
}; };
onChange = mapping => { onChange = (mapping) => {
let inputError = null; let inputError = null;
if (mapping.type === MappingType.DashboardAddNew) { if (mapping.type === MappingType.DashboardAddNew) {
@@ -351,7 +352,8 @@ class MappingEditor extends React.Component {
trigger="click" trigger="click"
content={this.renderContent()} content={this.renderContent()}
visible={visible} visible={visible}
onVisibleChange={this.onVisibleChange}> onVisibleChange={this.onVisibleChange}
>
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}> <Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
<EditOutlinedIcon /> <EditOutlinedIcon />
</Button> </Button>
@@ -376,14 +378,14 @@ class TitleEditor extends React.Component {
title: "", // will be set on editing title: "", // will be set on editing
}; };
onPopupVisibleChange = showPopup => { onPopupVisibleChange = (showPopup) => {
this.setState({ this.setState({
showPopup, showPopup,
title: showPopup ? this.getMappingTitle() : "", title: showPopup ? this.getMappingTitle() : "",
}); });
}; };
onEditingTitleChange = event => { onEditingTitleChange = (event) => {
this.setState({ title: event.target.value }); this.setState({ title: event.target.value });
}; };
@@ -460,7 +462,8 @@ class TitleEditor extends React.Component {
trigger="click" trigger="click"
content={this.renderPopover()} content={this.renderPopover()}
visible={this.state.showPopup} visible={this.state.showPopup}
onVisibleChange={this.onPopupVisibleChange}> onVisibleChange={this.onPopupVisibleChange}
>
<Button size="small" type="dashed"> <Button size="small" type="dashed">
<EditOutlinedIcon /> <EditOutlinedIcon />
</Button> </Button>
@@ -508,7 +511,7 @@ export class ParameterMappingListInput extends React.Component {
// just to be safe, array or object // just to be safe, array or object
if (typeof value === "object") { if (typeof value === "object") {
return map(value, v => this.getStringValue(v)).join(", "); return map(value, (v) => this.getStringValue(v)).join(", ");
} }
// rest // rest
@@ -574,7 +577,7 @@ export class ParameterMappingListInput extends React.Component {
render() { render() {
const { existingParams } = this.props; // eslint-disable-line react/prop-types const { existingParams } = this.props; // eslint-disable-line react/prop-types
const dataSource = this.props.mappings.map(mapping => ({ mapping })); const dataSource = this.props.mappings.map((mapping) => ({ mapping }));
return ( return (
<div className="parameters-mapping-list"> <div className="parameters-mapping-list">
@@ -583,11 +586,11 @@ export class ParameterMappingListInput extends React.Component {
title="Title" title="Title"
dataIndex="mapping" dataIndex="mapping"
key="title" key="title"
render={mapping => ( render={(mapping) => (
<TitleEditor <TitleEditor
existingParams={existingParams} existingParams={existingParams}
mapping={mapping} mapping={mapping}
onChange={newMapping => this.updateParamMapping(mapping, newMapping)} onChange={(newMapping) => this.updateParamMapping(mapping, newMapping)}
/> />
)} )}
/> />
@@ -596,19 +599,19 @@ export class ParameterMappingListInput extends React.Component {
dataIndex="mapping" dataIndex="mapping"
key="keyword" key="keyword"
className="keyword" className="keyword"
render={mapping => <code>{`{{ ${mapping.name} }}`}</code>} render={(mapping) => <code>{`{{ ${mapping.name} }}`}</code>}
/> />
<Table.Column <Table.Column
title="Default Value" title="Default Value"
dataIndex="mapping" dataIndex="mapping"
key="value" key="value"
render={mapping => this.constructor.getDefaultValue(mapping, this.props.existingParams)} render={(mapping) => this.constructor.getDefaultValue(mapping, this.props.existingParams)}
/> />
<Table.Column <Table.Column
title="Value Source" title="Value Source"
dataIndex="mapping" dataIndex="mapping"
key="source" key="source"
render={mapping => { render={(mapping) => {
const existingParamsNames = existingParams const existingParamsNames = existingParams
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types .filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
.map(({ name }) => name); // keep names only .map(({ name }) => name); // keep names only

View File

@@ -9,11 +9,12 @@ import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParamet
import QueryBasedParameterInput from "./QueryBasedParameterInput"; import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less"; import "./ParameterValueInput.less";
import Tooltip from "./Tooltip";
const multipleValuesProps = { const multipleValuesProps = {
maxTagCount: 3, maxTagCount: 3,
maxTagTextLength: 10, maxTagTextLength: 10,
maxTagPlaceholder: num => `+${num.length} more`, maxTagPlaceholder: (num) => `+${num.length} more`,
}; };
class ParameterValueInput extends React.Component { class ParameterValueInput extends React.Component {
@@ -25,6 +26,7 @@ class ParameterValueInput extends React.Component {
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func, onSelect: PropTypes.func,
className: PropTypes.string, className: PropTypes.string,
regex: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@@ -35,6 +37,7 @@ class ParameterValueInput extends React.Component {
parameter: null, parameter: null,
onSelect: () => {}, onSelect: () => {},
className: "", className: "",
regex: "",
}; };
constructor(props) { constructor(props) {
@@ -45,7 +48,7 @@ class ParameterValueInput extends React.Component {
}; };
} }
componentDidUpdate = prevProps => { componentDidUpdate = (prevProps) => {
const { value, parameter } = this.props; const { value, parameter } = this.props;
// if value prop updated, reset dirty state // if value prop updated, reset dirty state
if (prevProps.value !== value || prevProps.parameter !== parameter) { if (prevProps.value !== value || prevProps.parameter !== parameter) {
@@ -56,7 +59,7 @@ class ParameterValueInput extends React.Component {
} }
}; };
onSelect = value => { onSelect = (value) => {
const isDirty = !isEqual(value, this.props.value); const isDirty = !isEqual(value, this.props.value);
this.setState({ value, isDirty }); this.setState({ value, isDirty });
this.props.onSelect(value, isDirty); this.props.onSelect(value, isDirty);
@@ -93,9 +96,9 @@ class ParameterValueInput extends React.Component {
renderEnumInput() { renderEnumInput() {
const { enumOptions, parameter } = this.props; const { enumOptions, parameter } = this.props;
const { value } = this.state; 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 // Antd Select doesn't handle null in multiple mode
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val); const normalize = (val) => (parameter.multiValuesOptions && val === null ? [] : val);
return ( return (
<SelectWithVirtualScroll <SelectWithVirtualScroll
@@ -103,7 +106,7 @@ class ParameterValueInput extends React.Component {
mode={parameter.multiValuesOptions ? "multiple" : "default"} mode={parameter.multiValuesOptions ? "multiple" : "default"}
value={normalize(value)} value={normalize(value)}
onChange={this.onSelect} onChange={this.onSelect}
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))} options={map(enumOptionsArray, (opt) => ({ label: String(opt), value: opt }))}
showSearch showSearch
showArrow showArrow
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null} notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
@@ -133,18 +136,36 @@ class ParameterValueInput extends React.Component {
const { className } = this.props; const { className } = this.props;
const { value } = this.state; const { value } = this.state;
const normalize = val => (isNaN(val) ? undefined : val); const normalize = (val) => (isNaN(val) ? undefined : val);
return ( return (
<InputNumber <InputNumber
className={className} className={className}
value={normalize(value)} value={normalize(value)}
aria-label="Parameter number value" aria-label="Parameter number value"
onChange={val => this.onSelect(normalize(val))} onChange={(val) => this.onSelect(normalize(val))}
/> />
); );
} }
renderTextPatternInput() {
const { className } = this.props;
const { value } = this.state;
return (
<React.Fragment>
<Tooltip title={`Regex to match: ${this.props.regex}`} placement="right">
<Input
className={className}
value={value}
aria-label="Parameter text pattern value"
onChange={(e) => this.onSelect(e.target.value)}
/>
</Tooltip>
</React.Fragment>
);
}
renderTextInput() { renderTextInput() {
const { className } = this.props; const { className } = this.props;
const { value } = this.state; const { value } = this.state;
@@ -155,7 +176,7 @@ class ParameterValueInput extends React.Component {
value={value} value={value}
aria-label="Parameter text value" aria-label="Parameter text value"
data-test="TextParamInput" data-test="TextParamInput"
onChange={e => this.onSelect(e.target.value)} onChange={(e) => this.onSelect(e.target.value)}
/> />
); );
} }
@@ -177,6 +198,8 @@ class ParameterValueInput extends React.Component {
return this.renderQueryBasedInput(); return this.renderQueryBasedInput();
case "number": case "number":
return this.renderNumberInput(); return this.renderNumberInput();
case "text-pattern":
return this.renderTextPatternInput();
default: default:
return this.renderTextInput(); return this.renderTextInput();
} }

View File

@@ -1,4 +1,4 @@
import { size, filter, forEach, extend } from "lodash"; import { size, filter, forEach, extend, isEmpty } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { SortableContainer, SortableElement, DragHandle } from "@redash/viz/lib/components/sortable"; import { SortableContainer, SortableElement, DragHandle } from "@redash/viz/lib/components/sortable";
@@ -14,7 +14,7 @@ import "./Parameters.less";
function updateUrl(parameters) { function updateUrl(parameters) {
const params = extend({}, location.search); const params = extend({}, location.search);
parameters.forEach(param => { parameters.forEach((param) => {
extend(params, param.toUrlParams()); extend(params, param.toUrlParams());
}); });
location.setSearch(params, true); location.setSearch(params, true);
@@ -43,16 +43,26 @@ export default class Parameters extends React.Component {
appendSortableToParent: true, appendSortableToParent: true,
}; };
toCamelCase = (str) => {
if (isEmpty(str)) {
return "";
}
return str.replace(/\s+/g, "").toLowerCase();
};
constructor(props) { constructor(props) {
super(props); super(props);
const { parameters } = props; const { parameters, disableUrlUpdate } = props;
this.state = { parameters }; this.state = { parameters };
if (!props.disableUrlUpdate) { if (!disableUrlUpdate) {
updateUrl(parameters); updateUrl(parameters);
} }
const hideRegex = /hide_filter=([^&]+)/g;
const matches = window.location.search.matchAll(hideRegex);
this.hideValues = Array.from(matches, (match) => match[1]);
} }
componentDidUpdate = prevProps => { componentDidUpdate = (prevProps) => {
const { parameters, disableUrlUpdate } = this.props; const { parameters, disableUrlUpdate } = this.props;
const parametersChanged = prevProps.parameters !== parameters; const parametersChanged = prevProps.parameters !== parameters;
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate; const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
@@ -64,7 +74,7 @@ export default class Parameters extends React.Component {
} }
}; };
handleKeyDown = e => { handleKeyDown = (e) => {
// Cmd/Ctrl/Alt + Enter // Cmd/Ctrl/Alt + Enter
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
e.stopPropagation(); e.stopPropagation();
@@ -99,8 +109,8 @@ export default class Parameters extends React.Component {
applyChanges = () => { applyChanges = () => {
const { onValuesChange, disableUrlUpdate } = this.props; const { onValuesChange, disableUrlUpdate } = this.props;
this.setState(({ parameters }) => { this.setState(({ parameters }) => {
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue); const parametersWithPendingValues = parameters.filter((p) => p.hasPendingValue);
forEach(parameters, p => p.applyPendingValue()); forEach(parameters, (p) => p.applyPendingValue());
if (!disableUrlUpdate) { if (!disableUrlUpdate) {
updateUrl(parameters); updateUrl(parameters);
} }
@@ -111,7 +121,7 @@ export default class Parameters extends React.Component {
showParameterSettings = (parameter, index) => { showParameterSettings = (parameter, index) => {
const { onParametersEdit } = this.props; const { onParametersEdit } = this.props;
EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => { EditParameterSettingsDialog.showModal({ parameter }).onClose((updated) => {
this.setState(({ parameters }) => { this.setState(({ parameters }) => {
const updatedParameter = extend(parameter, updated); const updatedParameter = extend(parameter, updated);
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId); parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
@@ -122,7 +132,13 @@ export default class Parameters extends React.Component {
}; };
renderParameter(param, index) { renderParameter(param, index) {
if (this.hideValues.some((value) => this.toCamelCase(value) === this.toCamelCase(param.name))) {
return null;
}
const { editable } = this.props; const { editable } = this.props;
if (param.hidden) {
return null;
}
return ( return (
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}> <div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
<div className="parameter-heading"> <div className="parameter-heading">
@@ -133,11 +149,13 @@ export default class Parameters extends React.Component {
aria-label="Edit" aria-label="Edit"
onClick={() => this.showParameterSettings(param, index)} onClick={() => this.showParameterSettings(param, index)}
data-test={`ParameterSettings-${param.name}`} data-test={`ParameterSettings-${param.name}`}
type="button"> type="button"
>
<i className="fa fa-cog" aria-hidden="true" /> <i className="fa fa-cog" aria-hidden="true" />
</PlainButton> </PlainButton>
)} )}
</div> </div>
<ParameterValueInput <ParameterValueInput
type={param.type} type={param.type}
value={param.normalizedValue} value={param.normalizedValue}
@@ -145,6 +163,7 @@ export default class Parameters extends React.Component {
enumOptions={param.enumOptions} enumOptions={param.enumOptions}
queryId={param.queryId} queryId={param.queryId}
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)} onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
regex={param.regex}
/> />
</div> </div>
); );
@@ -154,7 +173,6 @@ export default class Parameters extends React.Component {
const { parameters } = this.state; const { parameters } = this.state;
const { sortable, appendSortableToParent } = this.props; const { sortable, appendSortableToParent } = this.props;
const dirtyParamCount = size(filter(parameters, "hasPendingValue")); const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
return ( return (
<SortableContainer <SortableContainer
disabled={!sortable} disabled={!sortable}
@@ -162,24 +180,27 @@ export default class Parameters extends React.Component {
useDragHandle useDragHandle
lockToContainerEdges lockToContainerEdges
helperClass="parameter-dragged" helperClass="parameter-dragged"
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)} helperContainer={(containerEl) => (appendSortableToParent ? containerEl : document.body)}
updateBeforeSortStart={this.onBeforeSortStart} updateBeforeSortStart={this.onBeforeSortStart}
onSortEnd={this.moveParameter} onSortEnd={this.moveParameter}
containerProps={{ containerProps={{
className: "parameter-container", className: "parameter-container",
onKeyDown: dirtyParamCount ? this.handleKeyDown : null, onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
}}> }}
{parameters.map((param, index) => ( >
<SortableElement key={param.name} index={index}> {parameters &&
<div parameters.map((param, index) => (
className="parameter-block" <SortableElement key={param.name} index={index}>
data-editable={sortable || null} <div
data-test={`ParameterBlock-${param.name}`}> className="parameter-block"
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />} data-editable={sortable || null}
{this.renderParameter(param, index)} data-test={`ParameterBlock-${param.name}`}
</div> >
</SortableElement> {sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
))} {this.renderParameter(param, index)}
</div>
</SortableElement>
))}
<ParameterApplyButton onClick={this.applyChanges} paramCount={dirtyParamCount} /> <ParameterApplyButton onClick={this.applyChanges} paramCount={dirtyParamCount} />
</SortableContainer> </SortableContainer>
); );

View File

@@ -69,7 +69,7 @@ UserPreviewCard.defaultProps = {
// DataSourcePreviewCard // DataSourcePreviewCard
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) { export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
const imageUrl = `static/images/db-logos/${dataSource.type}.png`; const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name; const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
return ( return (
<PreviewCard {...props} imageUrl={imageUrl} title={title}> <PreviewCard {...props} imageUrl={imageUrl} title={title}>

View File

@@ -51,7 +51,7 @@
right: 0; right: 0;
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px), background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent); linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: calc((100% + 15px) / 6) 5px; background-size: calc((100% + 15px) / 12) 5px;
background-position: -7px 1px; background-position: -7px 1px;
} }
} }
@@ -123,6 +123,7 @@
right: 10px; right: 10px;
bottom: 15px; bottom: 15px;
height: auto; height: auto;
overflow: hidden;
padding: 0; padding: 0;
} }
} }

View File

@@ -9,121 +9,85 @@ const DYNAMIC_DATE_OPTIONS = [
name: "This week", name: "This week",
value: getDynamicDateRangeFromString("d_this_week"), value: getDynamicDateRangeFromString("d_this_week"),
label: () => label: () =>
getDynamicDateRangeFromString("d_this_week") getDynamicDateRangeFromString("d_this_week").value()[0].format("MMM D") +
.value()[0]
.format("MMM D") +
" - " + " - " +
getDynamicDateRangeFromString("d_this_week") getDynamicDateRangeFromString("d_this_week").value()[1].format("MMM D"),
.value()[1]
.format("MMM D"),
}, },
{ {
name: "This month", name: "This month",
value: getDynamicDateRangeFromString("d_this_month"), value: getDynamicDateRangeFromString("d_this_month"),
label: () => label: () => getDynamicDateRangeFromString("d_this_month").value()[0].format("MMMM"),
getDynamicDateRangeFromString("d_this_month")
.value()[0]
.format("MMMM"),
}, },
{ {
name: "This year", name: "This year",
value: getDynamicDateRangeFromString("d_this_year"), value: getDynamicDateRangeFromString("d_this_year"),
label: () => label: () => getDynamicDateRangeFromString("d_this_year").value()[0].format("YYYY"),
getDynamicDateRangeFromString("d_this_year")
.value()[0]
.format("YYYY"),
}, },
{ {
name: "Last week", name: "Last week",
value: getDynamicDateRangeFromString("d_last_week"), value: getDynamicDateRangeFromString("d_last_week"),
label: () => label: () =>
getDynamicDateRangeFromString("d_last_week") getDynamicDateRangeFromString("d_last_week").value()[0].format("MMM D") +
.value()[0]
.format("MMM D") +
" - " + " - " +
getDynamicDateRangeFromString("d_last_week") getDynamicDateRangeFromString("d_last_week").value()[1].format("MMM D"),
.value()[1]
.format("MMM D"),
}, },
{ {
name: "Last month", name: "Last month",
value: getDynamicDateRangeFromString("d_last_month"), value: getDynamicDateRangeFromString("d_last_month"),
label: () => label: () => getDynamicDateRangeFromString("d_last_month").value()[0].format("MMMM"),
getDynamicDateRangeFromString("d_last_month")
.value()[0]
.format("MMMM"),
}, },
{ {
name: "Last year", name: "Last year",
value: getDynamicDateRangeFromString("d_last_year"), value: getDynamicDateRangeFromString("d_last_year"),
label: () => label: () => getDynamicDateRangeFromString("d_last_year").value()[0].format("YYYY"),
getDynamicDateRangeFromString("d_last_year")
.value()[0]
.format("YYYY"),
}, },
{ {
name: "Last 7 days", name: "Last 7 days",
value: getDynamicDateRangeFromString("d_last_7_days"), value: getDynamicDateRangeFromString("d_last_7_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_7_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_7_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 14 days", name: "Last 14 days",
value: getDynamicDateRangeFromString("d_last_14_days"), value: getDynamicDateRangeFromString("d_last_14_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_14_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_14_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 30 days", name: "Last 30 days",
value: getDynamicDateRangeFromString("d_last_30_days"), value: getDynamicDateRangeFromString("d_last_30_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_30_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_30_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 60 days", name: "Last 60 days",
value: getDynamicDateRangeFromString("d_last_60_days"), value: getDynamicDateRangeFromString("d_last_60_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_60_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_60_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 90 days", name: "Last 90 days",
value: getDynamicDateRangeFromString("d_last_90_days"), value: getDynamicDateRangeFromString("d_last_90_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_90_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_90_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 12 months", name: "Last 12 months",
value: getDynamicDateRangeFromString("d_last_12_months"), value: getDynamicDateRangeFromString("d_last_12_months"),
label: null, label: null,
}, },
{
name: "Last 10 years",
value: getDynamicDateRangeFromString("d_last_10_years"),
label: null,
},
]; ];
const DYNAMIC_DATETIME_OPTIONS = [ const DYNAMIC_DATETIME_OPTIONS = [
{ {
name: "Today", name: "Today",
value: getDynamicDateRangeFromString("d_today"), value: getDynamicDateRangeFromString("d_today"),
label: () => label: () => getDynamicDateRangeFromString("d_today").value()[0].format("MMM D"),
getDynamicDateRangeFromString("d_today")
.value()[0]
.format("MMM D"),
}, },
{ {
name: "Yesterday", name: "Yesterday",
value: getDynamicDateRangeFromString("d_yesterday"), value: getDynamicDateRangeFromString("d_yesterday"),
label: () => label: () => getDynamicDateRangeFromString("d_yesterday").value()[0].format("MMM D"),
getDynamicDateRangeFromString("d_yesterday")
.value()[0]
.format("MMM D"),
}, },
...DYNAMIC_DATE_OPTIONS, ...DYNAMIC_DATE_OPTIONS,
]; ];

View File

@@ -96,7 +96,7 @@ function EmptyState({
}, []); }, []);
// Show if `onboardingMode=false` or any requested step not completed // Show if `onboardingMode=false` or any requested step not completed
const shouldShow = !onboardingMode || some(keys(isAvailable), step => isAvailable[step] && !isCompleted[step]); const shouldShow = !onboardingMode || some(keys(isAvailable), (step) => isAvailable[step] && !isCompleted[step]);
if (!shouldShow) { if (!shouldShow) {
return null; return null;
@@ -181,7 +181,7 @@ function EmptyState({
]; ];
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems; const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg"; const imageSource = illustrationPath ? illustrationPath : "/static/images/illustrations/" + illustration + ".svg";
return ( return (
<div className="empty-state-wrapper"> <div className="empty-state-wrapper">
@@ -196,7 +196,7 @@ function EmptyState({
</div> </div>
<div className="empty-state__steps"> <div className="empty-state__steps">
<h4>Let&apos;s get started</h4> <h4>Let&apos;s get started</h4>
<ol>{stepsItems.map(item => item.node)}</ol> <ol>{stepsItems.map((item) => item.node)}</ol>
{helpMessage} {helpMessage}
</div> </div>
</div> </div>

View File

@@ -10,6 +10,10 @@ export interface PaginationOptions {
itemsPerPage?: number; itemsPerPage?: number;
} }
export interface SearchOptions {
isServerSideFTS?: boolean;
}
export interface Controller<I, P = any> { export interface Controller<I, P = any> {
params: P; // TODO: Find out what params is (except merging with props) params: P; // TODO: Find out what params is (except merging with props)
@@ -18,7 +22,7 @@ export interface Controller<I, P = any> {
// search // search
searchTerm?: string; searchTerm?: string;
updateSearch: (searchTerm: string) => void; updateSearch: (searchTerm: string, searchOptions?: SearchOptions) => void;
// tags // tags
selectedTags: string[]; selectedTags: string[];
@@ -28,6 +32,7 @@ export interface Controller<I, P = any> {
orderByField?: string; orderByField?: string;
orderByReverse: boolean; orderByReverse: boolean;
toggleSorting: (orderByField: string) => void; toggleSorting: (orderByField: string) => void;
setSorting: (orderByField: string, orderByReverse: boolean) => void;
// pagination // pagination
page: number; page: number;
@@ -93,7 +98,7 @@ export interface ItemsListWrappedComponentProps<I, P = any> {
export function wrap<I, P = any>( export function wrap<I, P = any>(
WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>, WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>,
createItemsSource: () => ItemsSource, createItemsSource: () => ItemsSource,
createStateStorage: () => StateStorage createStateStorage: ( { ...props }) => StateStorage
) { ) {
class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> { class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> {
private _itemsSource: ItemsSource; private _itemsSource: ItemsSource;
@@ -116,7 +121,7 @@ export function wrap<I, P = any>(
constructor(props: ItemsListWrapperProps) { constructor(props: ItemsListWrapperProps) {
super(props); super(props);
const stateStorage = createStateStorage(); const stateStorage = createStateStorage({ ...props });
const itemsSource = createItemsSource(); const itemsSource = createItemsSource();
this._itemsSource = itemsSource; this._itemsSource = itemsSource;
@@ -139,11 +144,33 @@ export function wrap<I, P = any>(
this.props.onError!(error); this.props.onError!(error);
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false }); const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource; const { updatePagination, toggleSorting, setSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
let isRunningUpdateSearch = false;
let pendingUpdateSearchParams: any[] | null = null;
const debouncedUpdateSearch = debounce(async (...params) => {
// Avoid running multiple updateSerch concurrently.
// If an updateSearch is already running, we save the params for the latest call.
// When the current updateSearch is finished, we call debouncedUpdateSearch again with the saved params.
if (isRunningUpdateSearch) {
pendingUpdateSearchParams = params;
return;
}
isRunningUpdateSearch = true;
await updateSearch(...params);
isRunningUpdateSearch = false;
if (pendingUpdateSearchParams) {
const pendingParams = pendingUpdateSearchParams;
pendingUpdateSearchParams = null;
debouncedUpdateSearch(...pendingParams);
}
}, 200);
this.state = { this.state = {
...initialState, ...initialState,
toggleSorting, // eslint-disable-line react/no-unused-state toggleSorting, // eslint-disable-line react/no-unused-state
updateSearch: debounce(updateSearch, 200), // eslint-disable-line react/no-unused-state setSorting, // eslint-disable-line react/no-unused-state
updateSearch: debouncedUpdateSearch, // eslint-disable-line react/no-unused-state
updateSelectedTags, // eslint-disable-line react/no-unused-state updateSelectedTags, // eslint-disable-line react/no-unused-state
updatePagination, // eslint-disable-line react/no-unused-state updatePagination, // eslint-disable-line react/no-unused-state
update, // eslint-disable-line react/no-unused-state update, // eslint-disable-line react/no-unused-state

View File

@@ -39,14 +39,12 @@ export class ItemsSource {
const customParams = {}; const customParams = {};
const context = { const context = {
...this.getCallbackContext(), ...this.getCallbackContext(),
setCustomParams: params => { setCustomParams: (params) => {
extend(customParams, params); extend(customParams, params);
}, },
}; };
return this._beforeUpdate().then(() => { return this._beforeUpdate().then(() => {
const fetchToken = Math.random() const fetchToken = Math.random().toString(36).substr(2);
.toString(36)
.substr(2);
this._currentFetchToken = fetchToken; this._currentFetchToken = fetchToken;
return this._fetcher return this._fetcher
.fetch(changes, state, context) .fetch(changes, state, context)
@@ -59,7 +57,7 @@ export class ItemsSource {
return this._afterUpdate(); return this._afterUpdate();
} }
}) })
.catch(error => this.handleError(error)); .catch((error) => this.handleError(error));
}); });
} }
@@ -124,28 +122,35 @@ export class ItemsSource {
}); });
}; };
toggleSorting = orderByField => { toggleSorting = (orderByField) => {
this._sorter.toggleField(orderByField); this._sorter.toggleField(orderByField);
this._savedOrderByField = this._sorter.field; this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true }); this._changed({ sorting: true });
}; };
updateSearch = searchTerm => { setSorting = (orderByField, orderByReverse) => {
this._sorter.setField(orderByField);
this._sorter.setReverse(orderByReverse);
this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true });
};
updateSearch = (searchTerm, options) => {
// here we update state directly, but later `fetchData` will update it properly // here we update state directly, but later `fetchData` will update it properly
this._searchTerm = searchTerm; this._searchTerm = searchTerm;
// in search mode ignore the ordering and use the ranking order // in search mode ignore the ordering and use the ranking order
// provided by the server-side FTS backend instead, unless it was // provided by the server-side FTS backend instead, unless it was
// requested by the user by actively ordering in search mode // requested by the user by actively ordering in search mode
if (searchTerm === "") { if (searchTerm === "" || !options?.isServerSideFTS) {
this._sorter.setField(this._savedOrderByField); // restore ordering this._sorter.setField(this._savedOrderByField); // restore ordering
} else { } else {
this._sorter.setField(null); this._sorter.setField(null);
} }
this._paginator.setPage(1); this._paginator.setPage(1);
this._changed({ search: true, pagination: { page: true } }); return this._changed({ search: true, pagination: { page: true } });
}; };
updateSelectedTags = selectedTags => { updateSelectedTags = (selectedTags) => {
this._selectedTags = selectedTags; this._selectedTags = selectedTags;
this._paginator.setPage(1); this._paginator.setPage(1);
this._changed({ tags: true, pagination: { page: true } }); this._changed({ tags: true, pagination: { page: true } });
@@ -153,7 +158,7 @@ export class ItemsSource {
update = () => this._changed(); update = () => this._changed();
handleError = error => { handleError = (error) => {
if (isFunction(this.onError)) { if (isFunction(this.onError)) {
this.onError(error); this.onError(error);
} }
@@ -172,7 +177,7 @@ export class ResourceItemsSource extends ItemsSource {
processResults: (results, context) => { processResults: (results, context) => {
let processItem = getItemProcessor(context); let processItem = getItemProcessor(context);
processItem = isFunction(processItem) ? processItem : identity; processItem = isFunction(processItem) ? processItem : identity;
return map(results, item => processItem(item, context)); return map(results, (item) => processItem(item, context));
}, },
}); });
} }

View File

@@ -44,7 +44,7 @@ export const Columns = {
date(overrides) { date(overrides) {
return extend( return extend(
{ {
render: text => formatDate(text), render: (text) => formatDate(text),
}, },
overrides overrides
); );
@@ -52,7 +52,7 @@ export const Columns = {
dateTime(overrides) { dateTime(overrides) {
return extend( return extend(
{ {
render: text => formatDateTime(text), render: (text) => formatDateTime(text),
}, },
overrides overrides
); );
@@ -62,7 +62,7 @@ export const Columns = {
{ {
width: "1%", width: "1%",
className: "text-nowrap", className: "text-nowrap",
render: text => durationHumanize(text), render: (text) => durationHumanize(text),
}, },
overrides overrides
); );
@@ -70,7 +70,7 @@ export const Columns = {
timeAgo(overrides, timeAgoCustomProps = undefined) { timeAgo(overrides, timeAgoCustomProps = undefined) {
return extend( return extend(
{ {
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />, render: (value) => <TimeAgo date={value} {...timeAgoCustomProps} />,
}, },
overrides overrides
); );
@@ -110,6 +110,7 @@ export default class ItemsTable extends React.Component {
orderByField: PropTypes.string, orderByField: PropTypes.string,
orderByReverse: PropTypes.bool, orderByReverse: PropTypes.bool,
toggleSorting: PropTypes.func, toggleSorting: PropTypes.func,
setSorting: PropTypes.func,
"data-test": PropTypes.string, "data-test": PropTypes.string,
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
}; };
@@ -127,18 +128,15 @@ export default class ItemsTable extends React.Component {
}; };
prepareColumns() { prepareColumns() {
const { orderByField, orderByReverse, toggleSorting } = this.props; const { orderByField, orderByReverse } = this.props;
const orderByDirection = orderByReverse ? "descend" : "ascend"; const orderByDirection = orderByReverse ? "descend" : "ascend";
return map( return map(
map( map(
filter(this.props.columns, column => (isFunction(column.isAvailable) ? column.isAvailable() : true)), filter(this.props.columns, (column) => (isFunction(column.isAvailable) ? column.isAvailable() : true)),
column => extend(column, { orderByField: column.orderByField || column.field }) (column) => extend(column, { orderByField: column.orderByField || column.field })
), ),
(column, index) => { (column, index) => {
// Bind click events only to sortable columns
const onHeaderCell = column.sorter ? () => ({ onClick: () => toggleSorting(column.orderByField) }) : null;
// Wrap render function to pass correct arguments // Wrap render function to pass correct arguments
const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity; const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity;
@@ -146,14 +144,13 @@ export default class ItemsTable extends React.Component {
key: "column" + index, key: "column" + index,
dataIndex: ["item", column.field], dataIndex: ["item", column.field],
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null, defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
onHeaderCell,
render, render,
}); });
} }
); );
} }
getRowKey = record => { getRowKey = (record) => {
const { rowKey } = this.props; const { rowKey } = this.props;
if (rowKey) { if (rowKey) {
if (isFunction(rowKey)) { if (isFunction(rowKey)) {
@@ -172,22 +169,43 @@ export default class ItemsTable extends React.Component {
// Bind events only if `onRowClick` specified // Bind events only if `onRowClick` specified
const onTableRow = isFunction(this.props.onRowClick) const onTableRow = isFunction(this.props.onRowClick)
? row => ({ ? (row) => ({
onClick: event => { onClick: (event) => {
this.props.onRowClick(event, row.item); this.props.onRowClick(event, row.item);
}, },
}) })
: null; : null;
const onChange = (pagination, filters, sorter, extra) => {
const action = extra?.action;
if (action === "sort") {
const propsColumn = this.props.columns.find((column) => column.field === sorter.field[1]);
if (!propsColumn.sorter) {
return;
}
let orderByField = propsColumn.orderByField;
const orderByReverse = sorter.order === "descend";
if (orderByReverse === undefined) {
orderByField = null;
}
if (this.props.setSorting) {
this.props.setSorting(orderByField, orderByReverse);
} else {
this.props.toggleSorting(orderByField);
}
}
};
const { showHeader } = this.props; const { showHeader } = this.props;
if (this.props.loading) { if (this.props.loading) {
if (isEmpty(tableDataProps.dataSource)) { if (isEmpty(tableDataProps.dataSource)) {
tableDataProps.columns = tableDataProps.columns.map(column => ({ tableDataProps.columns = tableDataProps.columns.map((column) => ({
...column, ...column,
sorter: false, sorter: false,
render: () => <Skeleton active paragraph={false} />, render: () => <Skeleton active paragraph={false} />,
})); }));
tableDataProps.dataSource = range(10).map(key => ({ key: `${key}` })); tableDataProps.dataSource = range(10).map((key) => ({ key: `${key}` }));
} else { } else {
tableDataProps.loading = { indicator: null }; tableDataProps.loading = { indicator: null };
} }
@@ -200,6 +218,7 @@ export default class ItemsTable extends React.Component {
rowKey={this.getRowKey} rowKey={this.getRowKey}
pagination={false} pagination={false}
onRow={onTableRow} onRow={onTableRow}
onChange={onChange}
data-test={this.props["data-test"]} data-test={this.props["data-test"]}
{...tableDataProps} {...tableDataProps}
/> />

View File

@@ -65,6 +65,7 @@ export const Query = PropTypes.shape({
export const AlertOptions = PropTypes.shape({ export const AlertOptions = PropTypes.shape({
column: PropTypes.string, column: PropTypes.string,
selector: PropTypes.oneOf(["first", "min", "max"]),
op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]), op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
custom_subject: PropTypes.string, custom_subject: PropTypes.string,
@@ -83,6 +84,7 @@ export const Alert = PropTypes.shape({
query: Query, query: Query,
options: PropTypes.shape({ options: PropTypes.shape({
column: PropTypes.string, column: PropTypes.string,
selector: PropTypes.string,
op: PropTypes.string, op: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}).isRequired, }).isRequired,

View File

@@ -47,20 +47,30 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
return ( return (
<div {...props}> <div {...props}>
<div className="schema-list-item"> <div className="schema-list-item">
<PlainButton className="table-name" onClick={onToggle}> <Tooltip
<i className="fa fa-table m-r-5" aria-hidden="true" /> title={item.description}
<strong> mouseEnterDelay={0}
<span title={item.name}>{tableDisplayName}</span> mouseLeaveDelay={0}
{!isNil(item.size) && <span> ({item.size})</span>} placement="rightTop"
</strong> trigger={item.description ? "hover" : ""}
</PlainButton> overlayStyle={{ whiteSpace: "pre-line" }}
>
<PlainButton className="table-name" onClick={onToggle}>
<i className="fa fa-table m-r-5" aria-hidden="true" />
<strong>
<span title={item.name}>{tableDisplayName}</span>
{!isNil(item.size) && <span> ({item.size})</span>}
</strong>
</PlainButton>
</Tooltip>
<Tooltip <Tooltip
title="Insert table name into query text" title="Insert table name into query text"
mouseEnterDelay={0} mouseEnterDelay={0}
mouseLeaveDelay={0} mouseLeaveDelay={0}
placement="topRight" placement="topRight"
arrowPointAtCenter> arrowPointAtCenter
<PlainButton className="copy-to-editor" onClick={e => handleSelect(e, item.name)}> >
<PlainButton className="copy-to-editor" onClick={(e) => handleSelect(e, item.name)}>
<i className="fa fa-angle-double-right" aria-hidden="true" /> <i className="fa fa-angle-double-right" aria-hidden="true" />
</PlainButton> </PlainButton>
</Tooltip> </Tooltip>
@@ -70,16 +80,23 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
{item.loading ? ( {item.loading ? (
<div className="table-open">Loading...</div> <div className="table-open">Loading...</div>
) : ( ) : (
map(item.columns, column => { map(item.columns, (column) => {
const columnName = get(column, "name"); const columnName = get(column, "name");
const columnType = get(column, "type"); const columnType = get(column, "type");
const columnDescription = get(column, "description");
return ( return (
<Tooltip <Tooltip
title="Insert column name into query text" title={"Insert column name into query text" + (columnDescription ? "\n" + columnDescription : "")}
mouseEnterDelay={0} mouseEnterDelay={0}
mouseLeaveDelay={0} mouseLeaveDelay={0}
placement="rightTop"> placement="rightTop"
<PlainButton key={columnName} className="table-open-item" onClick={e => handleSelect(e, columnName)}> overlayStyle={{ whiteSpace: "pre-line" }}
>
<PlainButton
key={columnName}
className="table-open-item"
onClick={(e) => handleSelect(e, columnName)}
>
<div> <div>
{columnName} {columnType && <span className="column-type">{columnType}</span>} {columnName} {columnType && <span className="column-type">{columnType}</span>}
</div> </div>
@@ -168,7 +185,7 @@ export function SchemaList({ loading, schema, expandedFlags, onTableExpand, onIt
} }
export function applyFilterOnSchema(schema, filterString) { export function applyFilterOnSchema(schema, filterString) {
const filters = filter(filterString.toLowerCase().split(/\s+/), s => s.length > 0); const filters = filter(filterString.toLowerCase().split(/\s+/), (s) => s.length > 0);
// Empty string: return original schema // Empty string: return original schema
if (filters.length === 0) { if (filters.length === 0) {
@@ -181,9 +198,9 @@ export function applyFilterOnSchema(schema, filterString) {
const columnFilter = filters[0]; const columnFilter = filters[0];
return filter( return filter(
schema, schema,
item => (item) =>
includes(item.name.toLowerCase(), nameFilter) || includes(item.name.toLowerCase(), nameFilter) ||
some(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter)) some(item.columns, (column) => includes(get(column, "name").toLowerCase(), columnFilter))
); );
} }
@@ -191,11 +208,11 @@ export function applyFilterOnSchema(schema, filterString) {
const nameFilter = filters[0]; const nameFilter = filters[0];
const columnFilter = filters[1]; const columnFilter = filters[1];
return filter( return filter(
map(schema, item => { map(schema, (item) => {
if (includes(item.name.toLowerCase(), nameFilter)) { if (includes(item.name.toLowerCase(), nameFilter)) {
item = { item = {
...item, ...item,
columns: filter(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter)), columns: filter(item.columns, (column) => includes(get(column, "name").toLowerCase(), columnFilter)),
}; };
return item.columns.length > 0 ? item : null; return item.columns.length > 0 ? item : null;
} }
@@ -243,7 +260,7 @@ export default function SchemaBrowser({
placeholder="Search schema..." placeholder="Search schema..."
aria-label="Search schema" aria-label="Search schema"
disabled={schema.length === 0} disabled={schema.length === 0}
onChange={event => handleFilterChange(event.target.value)} onChange={(event) => handleFilterChange(event.target.value)}
/> />
<Tooltip title="Refresh Schema"> <Tooltip title="Refresh Schema">

View File

@@ -148,7 +148,9 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
function dismiss() { function dismiss() {
const optionsChanged = !isEqual(options, defaultState.originalOptions); const optionsChanged = !isEqual(options, defaultState.originalOptions);
confirmDialogClose(nameChanged || optionsChanged).then(dialog.dismiss); confirmDialogClose(nameChanged || optionsChanged)
.then(dialog.dismiss)
.catch(() => {});
} }
// When editing existing visualization chart type selector is disabled, so add only existing visualization's // When editing existing visualization chart type selector is disabled, so add only existing visualization's

View File

@@ -59,6 +59,7 @@ function wrapComponentWithSettings(WrappedComponent) {
"dateTimeFormat", "dateTimeFormat",
"integerFormat", "integerFormat",
"floatFormat", "floatFormat",
"nullValue",
"booleanValues", "booleanValues",
"tableCellMaxJSONSize", "tableCellMaxJSONSize",
"allowCustomJSVisualizations", "allowCustomJSVisualizations",

View File

@@ -1,13 +1,13 @@
export default { export default {
columns: 6, // grid columns count columns: 12, // grid columns count
rowHeight: 50, // grid row height (incl. bottom padding) rowHeight: 50, // grid row height (incl. bottom padding)
margins: 15, // widget margins margins: 15, // widget margins
mobileBreakPoint: 800, mobileBreakPoint: 800,
// defaults for widgets // defaults for widgets
defaultSizeX: 3, defaultSizeX: 6,
defaultSizeY: 3, defaultSizeY: 3,
minSizeX: 1, minSizeX: 2,
maxSizeX: 6, maxSizeX: 12,
minSizeY: 1, minSizeY: 2,
maxSizeY: 1000, maxSizeY: 1000,
}; };

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" translate="no">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<base href="{{base_href}}" /> <base href="{{base_href}}" />
<title><%= htmlWebpackPlugin.options.title %></title> <title><%= htmlWebpackPlugin.options.title %></title>
<script src="/static/unsupportedRedirect.js" async></script> <script src="<%= htmlWebpackPlugin.options.staticPath %>unsupportedRedirect.js" async></script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" /> <link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />

View File

@@ -16,6 +16,7 @@ import MenuButton from "./components/MenuButton";
import AlertView from "./AlertView"; import AlertView from "./AlertView";
import AlertEdit from "./AlertEdit"; import AlertEdit from "./AlertEdit";
import AlertNew from "./AlertNew"; import AlertNew from "./AlertNew";
import notifications from "@/services/notifications";
const MODES = { const MODES = {
NEW: 0, NEW: 0,
@@ -64,6 +65,7 @@ class Alert extends React.Component {
this.setState({ this.setState({
alert: { alert: {
options: { options: {
selector: "first",
op: ">", op: ">",
value: 1, value: 1,
muted: false, muted: false,
@@ -75,7 +77,7 @@ class Alert extends React.Component {
} else { } else {
const { alertId } = this.props; const { alertId } = this.props;
AlertService.get({ id: alertId }) AlertService.get({ id: alertId })
.then(alert => { .then((alert) => {
if (this._isMounted) { if (this._isMounted) {
const canEdit = currentUser.canEdit(alert); const canEdit = currentUser.canEdit(alert);
@@ -93,7 +95,7 @@ class Alert extends React.Component {
this.onQuerySelected(alert.query); this.onQuerySelected(alert.query);
} }
}) })
.catch(error => { .catch((error) => {
if (this._isMounted) { if (this._isMounted) {
this.props.onError(error); this.props.onError(error);
} }
@@ -112,7 +114,7 @@ class Alert extends React.Component {
alert.rearm = pendingRearm || null; alert.rearm = pendingRearm || null;
return AlertService.save(alert) return AlertService.save(alert)
.then(alert => { .then((alert) => {
notification.success("Saved."); notification.success("Saved.");
navigateTo(`alerts/${alert.id}`, true); navigateTo(`alerts/${alert.id}`, true);
this.setState({ alert, mode: MODES.VIEW }); this.setState({ alert, mode: MODES.VIEW });
@@ -122,7 +124,7 @@ class Alert extends React.Component {
}); });
}; };
onQuerySelected = query => { onQuerySelected = (query) => {
this.setState(({ alert }) => ({ this.setState(({ alert }) => ({
alert: Object.assign(alert, { query }), alert: Object.assign(alert, { query }),
queryResult: null, queryResult: null,
@@ -130,7 +132,7 @@ class Alert extends React.Component {
if (query) { if (query) {
// get cached result for column names and values // get cached result for column names and values
new QueryService(query).getQueryResultPromise().then(queryResult => { new QueryService(query).getQueryResultPromise().then((queryResult) => {
if (this._isMounted) { if (this._isMounted) {
this.setState({ queryResult }); this.setState({ queryResult });
let { column } = this.state.alert.options; let { column } = this.state.alert.options;
@@ -146,18 +148,18 @@ class Alert extends React.Component {
} }
}; };
onNameChange = name => { onNameChange = (name) => {
const { alert } = this.state; const { alert } = this.state;
this.setState({ this.setState({
alert: Object.assign(alert, { name }), alert: Object.assign(alert, { name }),
}); });
}; };
onRearmChange = pendingRearm => { onRearmChange = (pendingRearm) => {
this.setState({ pendingRearm }); this.setState({ pendingRearm });
}; };
setAlertOptions = obj => { setAlertOptions = (obj) => {
const { alert } = this.state; const { alert } = this.state;
const options = { ...alert.options, ...obj }; const options = { ...alert.options, ...obj };
this.setState({ this.setState({
@@ -177,6 +179,17 @@ class Alert extends React.Component {
}); });
}; };
evaluate = () => {
const { alert } = this.state;
return AlertService.evaluate(alert)
.then(() => {
notification.success("Alert evaluated. Refresh page for updated status.");
})
.catch(() => {
notifications.error("Failed to evaluate alert.");
});
};
mute = () => { mute = () => {
const { alert } = this.state; const { alert } = this.state;
return AlertService.mute(alert) return AlertService.mute(alert)
@@ -223,7 +236,14 @@ class Alert extends React.Component {
const { queryResult, mode, canEdit, pendingRearm } = this.state; const { queryResult, mode, canEdit, pendingRearm } = this.state;
const menuButton = ( const menuButton = (
<MenuButton doDelete={this.delete} muted={muted} mute={this.mute} unmute={this.unmute} canEdit={canEdit} /> <MenuButton
doDelete={this.delete}
muted={muted}
mute={this.mute}
unmute={this.unmute}
canEdit={canEdit}
evaluate={this.evaluate}
/>
); );
const commonProps = { const commonProps = {
@@ -258,7 +278,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/alerts/new", path: "/alerts/new",
title: "New Alert", title: "New Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.NEW} />, render: (pageProps) => <Alert {...pageProps} mode={MODES.NEW} />,
}) })
); );
routes.register( routes.register(
@@ -266,7 +286,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/alerts/:alertId", path: "/alerts/:alertId",
title: "Alert", title: "Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.VIEW} />, render: (pageProps) => <Alert {...pageProps} mode={MODES.VIEW} />,
}) })
); );
routes.register( routes.register(
@@ -274,6 +294,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/alerts/:alertId/edit", path: "/alerts/:alertId/edit",
title: "Alert", title: "Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.EDIT} />, render: (pageProps) => <Alert {...pageProps} mode={MODES.EDIT} />,
}) })
); );

View File

@@ -68,13 +68,23 @@ export default class AlertView extends React.Component {
<> <>
<Title name={name} alert={alert}> <Title name={name} alert={alert}>
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} /> <DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}> {canEdit ? (
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}> <>
<i className="fa fa-edit m-r-5" aria-hidden="true" /> <Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
Edit <i className="fa fa-edit m-r-5" aria-hidden="true" />
</Button> Edit
{menuButton} </Button>
</Tooltip> {menuButton}
</>
) : (
<Tooltip title="You do not have sufficient permissions to edit this alert">
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
<i className="fa fa-edit m-r-5" aria-hidden="true" />
Edit
</Button>
{menuButton}
</Tooltip>
)}
</Title> </Title>
<div className="bg-white tiled p-20"> <div className="bg-white tiled p-20">
<Grid.Row type="flex" gutter={16}> <Grid.Row type="flex" gutter={16}>

View File

@@ -54,23 +54,74 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
return null; return null;
})(); })();
const columnHint = ( let columnHint;
<small className="alert-criteria-hint">
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code> if (alertOptions.selector === "first") {
</small> columnHint = (
); <small className="alert-criteria-hint">
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
</small>
);
} else if (alertOptions.selector === "max") {
columnHint = (
<small className="alert-criteria-hint">
Max column value is{" "}
<code className="p-0">
{toString(
Math.max(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
) || "unknown"}
</code>
</small>
);
} else if (alertOptions.selector === "min") {
columnHint = (
<small className="alert-criteria-hint">
Min column value is{" "}
<code className="p-0">
{toString(
Math.min(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
) || "unknown"}
</code>
</small>
);
}
return ( return (
<div data-test="Criteria"> <div data-test="Criteria">
<div className="input-title">
<span className="input-label">Selector</span>
{editMode ? (
<Select
value={alertOptions.selector}
onChange={(selector) => onChange({ selector })}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 80 }}
>
<Select.Option value="first" label="first">
first
</Select.Option>
<Select.Option value="min" label="min">
min
</Select.Option>
<Select.Option value="max" label="max">
max
</Select.Option>
</Select>
) : (
<DisabledInput minWidth={60}>{alertOptions.selector}</DisabledInput>
)}
</div>
<div className="input-title"> <div className="input-title">
<span className="input-label">Value column</span> <span className="input-label">Value column</span>
{editMode ? ( {editMode ? (
<Select <Select
value={alertOptions.column} value={alertOptions.column}
onChange={column => onChange({ column })} onChange={(column) => onChange({ column })}
dropdownMatchSelectWidth={false} dropdownMatchSelectWidth={false}
style={{ minWidth: 100 }}> style={{ minWidth: 100 }}
{columnNames.map(name => ( >
{columnNames.map((name) => (
<Select.Option key={name}>{name}</Select.Option> <Select.Option key={name}>{name}</Select.Option>
))} ))}
</Select> </Select>
@@ -83,10 +134,11 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
{editMode ? ( {editMode ? (
<Select <Select
value={alertOptions.op} value={alertOptions.op}
onChange={op => onChange({ op })} onChange={(op) => onChange({ op })}
optionLabelProp="label" optionLabelProp="label"
dropdownMatchSelectWidth={false} dropdownMatchSelectWidth={false}
style={{ width: 55 }}> style={{ width: 55 }}
>
<Select.Option value=">" label={CONDITIONS[">"]}> <Select.Option value=">" label={CONDITIONS[">"]}>
{CONDITIONS[">"]} greater than {CONDITIONS[">"]} greater than
</Select.Option> </Select.Option>
@@ -125,7 +177,7 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
id="threshold-criterion" id="threshold-criterion"
style={{ width: 90 }} style={{ width: 90 }}
value={alertOptions.value} value={alertOptions.value}
onChange={e => onChange({ value: e.target.value })} onChange={(e) => onChange({ value: e.target.value })}
/> />
) : ( ) : (
<DisabledInput minWidth={50}>{alertOptions.value}</DisabledInput> <DisabledInput minWidth={50}>{alertOptions.value}</DisabledInput>

View File

@@ -11,7 +11,7 @@ import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined"; import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import PlainButton from "@/components/PlainButton"; import PlainButton from "@/components/PlainButton";
export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) { export default function MenuButton({ doDelete, canEdit, mute, unmute, evaluate, muted }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const execute = useCallback(action => { const execute = useCallback(action => {
@@ -55,6 +55,9 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
<Menu.Item> <Menu.Item>
<PlainButton onClick={confirmDelete}>Delete</PlainButton> <PlainButton onClick={confirmDelete}>Delete</PlainButton>
</Menu.Item> </Menu.Item>
<Menu.Item>
<PlainButton onClick={() => execute(evaluate)}>Evaluate</PlainButton>
</Menu.Item>
</Menu> </Menu>
}> }>
<Button aria-label="More actions"> <Button aria-label="More actions">
@@ -69,6 +72,7 @@ MenuButton.propTypes = {
canEdit: PropTypes.bool.isRequired, canEdit: PropTypes.bool.isRequired,
mute: PropTypes.func.isRequired, mute: PropTypes.func.isRequired,
unmute: PropTypes.func.isRequired, unmute: PropTypes.func.isRequired,
evaluate: PropTypes.func.isRequired,
muted: PropTypes.bool, muted: PropTypes.bool,
}; };

View File

@@ -81,12 +81,19 @@ function DashboardListExtraActions(props) {
} }
function DashboardList({ controller }) { function DashboardList({ controller }) {
let usedListColumns = listColumns;
if (controller.params.currentPage === "favorites") {
usedListColumns = [
...usedListColumns,
Columns.dateTime.sortable({ title: "Starred At", field: "starred_at", width: "1%" }),
];
}
const { const {
areExtraActionsAvailable, areExtraActionsAvailable,
listColumns: tableColumns, listColumns: tableColumns,
Component: ExtraActionsComponent, Component: ExtraActionsComponent,
selectedItems, selectedItems,
} = useItemsListExtraActions(controller, listColumns, DashboardListExtraActions); } = useItemsListExtraActions(controller, usedListColumns, DashboardListExtraActions);
return ( return (
<div className="page-dashboard-list"> <div className="page-dashboard-list">
@@ -139,9 +146,9 @@ function DashboardList({ controller }) {
showPageSizeSelect showPageSizeSelect
totalCount={controller.totalItemsCount} totalCount={controller.totalItemsCount}
pageSize={controller.itemsPerPage} pageSize={controller.itemsPerPage}
onPageSizeChange={itemsPerPage => controller.updatePagination({ itemsPerPage })} onPageSizeChange={(itemsPerPage) => controller.updatePagination({ itemsPerPage })}
page={controller.page} page={controller.page}
onChange={page => controller.updatePagination({ page })} onChange={(page) => controller.updatePagination({ page })}
/> />
</div> </div>
</React.Fragment> </React.Fragment>
@@ -170,10 +177,10 @@ const DashboardListPage = itemsList(
}[currentPage]; }[currentPage];
}, },
getItemProcessor() { getItemProcessor() {
return item => new Dashboard(item); return (item) => new Dashboard(item);
}, },
}), }),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true }) ({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true })
); );
routes.register( routes.register(
@@ -181,7 +188,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards", path: "/dashboards",
title: "Dashboards", title: "Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="all" />, render: (pageProps) => <DashboardListPage {...pageProps} currentPage="all" />,
}) })
); );
routes.register( routes.register(
@@ -189,7 +196,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/favorites", path: "/dashboards/favorites",
title: "Favorite Dashboards", title: "Favorite Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />, render: (pageProps) => <DashboardListPage {...pageProps} currentPage="favorites" orderByField="starred_at" />,
}) })
); );
routes.register( routes.register(
@@ -197,6 +204,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/my", path: "/dashboards/my",
title: "My Dashboards", title: "My Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="my" />, render: (pageProps) => <DashboardListPage {...pageProps} currentPage="my" />,
}) })
); );

View File

@@ -31,7 +31,8 @@ function DashboardSettings({ dashboardConfiguration }) {
<Checkbox <Checkbox
checked={!!dashboard.dashboard_filters_enabled} checked={!!dashboard.dashboard_filters_enabled}
onChange={({ target }) => updateDashboard({ dashboard_filters_enabled: target.checked })} onChange={({ target }) => updateDashboard({ dashboard_filters_enabled: target.checked })}
data-test="DashboardFiltersCheckbox"> data-test="DashboardFiltersCheckbox"
>
Use Dashboard Level Filters Use Dashboard Level Filters
</Checkbox> </Checkbox>
</div> </div>
@@ -90,9 +91,9 @@ function DashboardComponent(props) {
const [pageContainer, setPageContainer] = useState(null); const [pageContainer, setPageContainer] = useState(null);
const [bottomPanelStyles, setBottomPanelStyles] = useState({}); const [bottomPanelStyles, setBottomPanelStyles] = useState({});
const onParametersEdit = parameters => { const onParametersEdit = (parameters) => {
const paramOrder = map(parameters, "name"); const paramOrder = map(parameters, "name");
updateDashboard({ options: { globalParamOrder: paramOrder } }); updateDashboard({ options: { ...dashboard.options, globalParamOrder: paramOrder } });
}; };
useEffect(() => { useEffect(() => {
@@ -175,7 +176,7 @@ function DashboardPage({ dashboardSlug, dashboardId, onError }) {
useEffect(() => { useEffect(() => {
Dashboard.get({ id: dashboardId, slug: dashboardSlug }) Dashboard.get({ id: dashboardId, slug: dashboardSlug })
.then(dashboardData => { .then((dashboardData) => {
recordEvent("view", "dashboard", dashboardData.id); recordEvent("view", "dashboard", dashboardData.id);
setDashboard(dashboardData); setDashboard(dashboardData);
@@ -207,7 +208,7 @@ routes.register(
"Dashboards.LegacyViewOrEdit", "Dashboards.LegacyViewOrEdit",
routeWithUserSession({ routeWithUserSession({
path: "/dashboard/:dashboardSlug", path: "/dashboard/:dashboardSlug",
render: pageProps => <DashboardPage {...pageProps} />, render: (pageProps) => <DashboardPage {...pageProps} />,
}) })
); );
@@ -215,6 +216,6 @@ routes.register(
"Dashboards.ViewOrEdit", "Dashboards.ViewOrEdit",
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/:dashboardId([^-]+)(-.*)?", path: "/dashboards/:dashboardId([^-]+)(-.*)?",
render: pageProps => <DashboardPage {...pageProps} />, render: (pageProps) => <DashboardPage {...pageProps} />,
}) })
); );

View File

@@ -8,7 +8,7 @@
} }
> .container { > .container {
min-height: calc(100vh - 95px); min-height: calc(100% - 95px);
} }
.loading-message { .loading-message {

View File

@@ -22,7 +22,7 @@ import { DashboardStatusEnum } from "../hooks/useDashboard";
import "./DashboardHeader.less"; import "./DashboardHeader.less";
function getDashboardTags() { function getDashboardTags() {
return getTags("api/dashboards/tags").then(tags => map(tags, t => t.name)); return getTags("api/dashboards/tags").then((tags) => map(tags, (t) => t.name));
} }
function buttonType(value) { function buttonType(value) {
@@ -38,7 +38,7 @@ function DashboardPageTitle({ dashboardConfiguration }) {
<h3> <h3>
<EditInPlace <EditInPlace
isEditable={editingLayout} isEditable={editingLayout}
onDone={name => updateDashboard({ name })} onDone={(name) => updateDashboard({ name })}
value={dashboard.name} value={dashboard.name}
ignoreBlanks ignoreBlanks
/> />
@@ -53,7 +53,7 @@ function DashboardPageTitle({ dashboardConfiguration }) {
isArchived={dashboard.is_archived} isArchived={dashboard.is_archived}
canEdit={canEditDashboard} canEdit={canEditDashboard}
getAvailableTags={getDashboardTags} getAvailableTags={getDashboardTags}
onEdit={tags => updateDashboard({ tags })} onEdit={(tags) => updateDashboard({ tags })}
/> />
</div> </div>
); );
@@ -89,14 +89,15 @@ function RefreshButton({ dashboardConfiguration }) {
placement="bottomRight" placement="bottomRight"
overlay={ overlay={
<Menu onClick={onRefreshRateSelected} selectedKeys={[`${refreshRate}`]}> <Menu onClick={onRefreshRateSelected} selectedKeys={[`${refreshRate}`]}>
{refreshRateOptions.map(option => ( {refreshRateOptions.map((option) => (
<Menu.Item key={`${option}`} disabled={!includes(allowedIntervals, option)}> <Menu.Item key={`${option}`} disabled={!includes(allowedIntervals, option)}>
{durationHumanize(option)} {durationHumanize(option)}
</Menu.Item> </Menu.Item>
))} ))}
{refreshRate && <Menu.Item key={null}>Disable auto refresh</Menu.Item>} {refreshRate && <Menu.Item key={null}>Disable auto refresh</Menu.Item>}
</Menu> </Menu>
}> }
>
<Button className="icon-button hidden-xs" type={buttonType(refreshRate)}> <Button className="icon-button hidden-xs" type={buttonType(refreshRate)}>
<i className="fa fa-angle-down" aria-hidden="true" /> <i className="fa fa-angle-down" aria-hidden="true" />
<span className="sr-only">Split button!</span> <span className="sr-only">Split button!</span>
@@ -119,6 +120,8 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
managePermissions, managePermissions,
gridDisabled, gridDisabled,
isDashboardOwnerOrAdmin, isDashboardOwnerOrAdmin,
isDuplicating,
duplicateDashboard,
} = dashboardConfiguration; } = dashboardConfiguration;
const archive = () => { const archive = () => {
@@ -142,6 +145,14 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
<Menu.Item className={cx({ hidden: gridDisabled })}> <Menu.Item className={cx({ hidden: gridDisabled })}>
<PlainButton onClick={() => setEditingLayout(true)}>Edit</PlainButton> <PlainButton onClick={() => setEditingLayout(true)}>Edit</PlainButton>
</Menu.Item> </Menu.Item>
{!isDuplicating && dashboard.canEdit() && (
<Menu.Item>
<PlainButton onClick={duplicateDashboard}>
Fork <i className="fa fa-external-link m-l-5" aria-hidden="true" />
<span className="sr-only">(opens in a new tab)</span>
</PlainButton>
</Menu.Item>
)}
{clientConfig.showPermissionsControl && isDashboardOwnerOrAdmin && ( {clientConfig.showPermissionsControl && isDashboardOwnerOrAdmin && (
<Menu.Item> <Menu.Item>
<PlainButton onClick={managePermissions}>Manage Permissions</PlainButton> <PlainButton onClick={managePermissions}>Manage Permissions</PlainButton>
@@ -156,7 +167,8 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
<PlainButton onClick={archive}>Archive</PlainButton> <PlainButton onClick={archive}>Archive</PlainButton>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
}> }
>
<Button className="icon-button m-l-5" data-test="DashboardMoreButton" aria-label="More actions"> <Button className="icon-button m-l-5" data-test="DashboardMoreButton" aria-label="More actions">
<EllipsisOutlinedIcon rotate={90} aria-hidden="true" /> <EllipsisOutlinedIcon rotate={90} aria-hidden="true" />
</Button> </Button>
@@ -206,7 +218,8 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
type={buttonType(fullscreen)} type={buttonType(fullscreen)}
className="icon-button m-l-5" className="icon-button m-l-5"
onClick={toggleFullscreen} onClick={toggleFullscreen}
aria-label="Toggle fullscreen display"> aria-label="Toggle fullscreen display"
>
<i className="zmdi zmdi-fullscreen" aria-hidden="true" /> <i className="zmdi zmdi-fullscreen" aria-hidden="true" />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -219,7 +232,8 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
type={buttonType(dashboard.publicAccessEnabled)} type={buttonType(dashboard.publicAccessEnabled)}
onClick={showShareDashboardDialog} onClick={showShareDashboardDialog}
data-test="OpenShareForm" data-test="OpenShareForm"
aria-label="Share"> aria-label="Share"
>
<i className="zmdi zmdi-share" aria-hidden="true" /> <i className="zmdi zmdi-share" aria-hidden="true" />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -242,7 +256,11 @@ function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
doneBtnClickedWhileSaving, doneBtnClickedWhileSaving,
dashboardStatus, dashboardStatus,
retrySaveDashboardLayout, retrySaveDashboardLayout,
saveDashboardParameters,
} = dashboardConfiguration; } = dashboardConfiguration;
const handleDoneEditing = () => {
saveDashboardParameters().then(() => setEditingLayout(false));
};
let status; let status;
if (dashboardStatus === DashboardStatusEnum.SAVED) { if (dashboardStatus === DashboardStatusEnum.SAVED) {
status = <span className="save-status">Saved</span>; status = <span className="save-status">Saved</span>;
@@ -267,7 +285,7 @@ function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
Retry Retry
</Button> </Button>
) : ( ) : (
<Button loading={doneBtnClickedWhileSaving} type="primary" onClick={() => setEditingLayout(false)}> <Button loading={doneBtnClickedWhileSaving} type="primary" onClick={handleDoneEditing}>
{!doneBtnClickedWhileSaving && <i className="fa fa-check m-r-5" aria-hidden="true" />} Done Editing {!doneBtnClickedWhileSaving && <i className="fa fa-check m-r-5" aria-hidden="true" />} Done Editing
</Button> </Button>
)} )}

View File

@@ -94,12 +94,12 @@ class ShareDashboardDialog extends React.Component {
}; };
render() { render() {
const { dialog, dashboard } = this.props; const { dialog, dashboard, hasOnlySafeQueries } = this.props;
const headerContent = this.constructor.headerContent;
return ( return (
<Modal {...dialog.props} title={this.constructor.headerContent} footer={null}> <Modal {...dialog.props} title={headerContent} footer={null}>
<Form layout="horizontal"> <Form layout="horizontal">
{!this.props.hasOnlySafeQueries && ( {!hasOnlySafeQueries && (
<Form.Item> <Form.Item>
<Alert <Alert
message="For your security, sharing is currently not supported for dashboards containing queries with text parameters. Consider changing the text parameters in your query to a different type." message="For your security, sharing is currently not supported for dashboards containing queries with text parameters. Consider changing the text parameters in your query to a different type."
@@ -107,6 +107,7 @@ class ShareDashboardDialog extends React.Component {
/> />
</Form.Item> </Form.Item>
)} )}
<Form.Item label="Allow public access" {...this.formItemProps}> <Form.Item label="Allow public access" {...this.formItemProps}>
<Switch <Switch
checked={dashboard.publicAccessEnabled} checked={dashboard.publicAccessEnabled}

View File

@@ -15,18 +15,19 @@ import ShareDashboardDialog from "../components/ShareDashboardDialog";
import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler"; import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler";
import useRefreshRateHandler from "./useRefreshRateHandler"; import useRefreshRateHandler from "./useRefreshRateHandler";
import useEditModeHandler from "./useEditModeHandler"; import useEditModeHandler from "./useEditModeHandler";
import useDuplicateDashboard from "./useDuplicateDashboard";
import { policy } from "@/services/policy"; import { policy } from "@/services/policy";
export { DashboardStatusEnum } from "./useEditModeHandler"; export { DashboardStatusEnum } from "./useEditModeHandler";
function getAffectedWidgets(widgets, updatedParameters = []) { function getAffectedWidgets(widgets, updatedParameters = []) {
return !isEmpty(updatedParameters) return !isEmpty(updatedParameters)
? widgets.filter(widget => ? widgets.filter((widget) =>
Object.values(widget.getParameterMappings()) Object.values(widget.getParameterMappings())
.filter(({ type }) => type === "dashboard-level") .filter(({ type }) => type === "dashboard-level")
.some(({ mapTo }) => .some(({ mapTo }) =>
includes( includes(
updatedParameters.map(p => p.name), updatedParameters.map((p) => p.name),
mapTo mapTo
) )
) )
@@ -49,10 +50,12 @@ function useDashboard(dashboardData) {
[dashboard] [dashboard]
); );
const hasOnlySafeQueries = useMemo( const hasOnlySafeQueries = useMemo(
() => every(dashboard.widgets, w => (w.getQuery() ? w.getQuery().is_safe : true)), () => every(dashboard.widgets, (w) => (w.getQuery() ? w.getQuery().is_safe : true)),
[dashboard] [dashboard]
); );
const [isDuplicating, duplicateDashboard] = useDuplicateDashboard(dashboard);
const managePermissions = useCallback(() => { const managePermissions = useCallback(() => {
const aclUrl = `api/dashboards/${dashboard.id}/acl`; const aclUrl = `api/dashboards/${dashboard.id}/acl`;
PermissionsEditorDialog.showModal({ PermissionsEditorDialog.showModal({
@@ -64,19 +67,19 @@ function useDashboard(dashboardData) {
const updateDashboard = useCallback( const updateDashboard = useCallback(
(data, includeVersion = true) => { (data, includeVersion = true) => {
setDashboard(currentDashboard => extend({}, currentDashboard, data)); setDashboard((currentDashboard) => extend({}, currentDashboard, data));
data = { ...data, id: dashboard.id }; data = { ...data, id: dashboard.id };
if (includeVersion) { if (includeVersion) {
data = { ...data, version: dashboard.version }; data = { ...data, version: dashboard.version };
} }
return Dashboard.save(data) return Dashboard.save(data)
.then(updatedDashboard => { .then((updatedDashboard) => {
setDashboard(currentDashboard => extend({}, currentDashboard, pick(updatedDashboard, keys(data)))); setDashboard((currentDashboard) => extend({}, currentDashboard, pick(updatedDashboard, keys(data))));
if (has(data, "name")) { if (has(data, "name")) {
location.setPath(url.parse(updatedDashboard.url).pathname, true); location.setPath(url.parse(updatedDashboard.url).pathname, true);
} }
}) })
.catch(error => { .catch((error) => {
const status = get(error, "response.status"); const status = get(error, "response.status");
if (status === 403) { if (status === 403) {
notification.error("Dashboard update failed", "Permission Denied."); notification.error("Dashboard update failed", "Permission Denied.");
@@ -99,25 +102,25 @@ function useDashboard(dashboardData) {
const loadWidget = useCallback((widget, forceRefresh = false) => { const loadWidget = useCallback((widget, forceRefresh = false) => {
widget.getParametersDefs(); // Force widget to read parameters values from URL widget.getParametersDefs(); // Force widget to read parameters values from URL
setDashboard(currentDashboard => extend({}, currentDashboard)); setDashboard((currentDashboard) => extend({}, currentDashboard));
return widget return widget
.load(forceRefresh) .load(forceRefresh)
.catch(error => { .catch((error) => {
// QueryResultErrors are expected // QueryResultErrors are expected
if (error instanceof QueryResultError) { if (error instanceof QueryResultError) {
return; return;
} }
return Promise.reject(error); return Promise.reject(error);
}) })
.finally(() => setDashboard(currentDashboard => extend({}, currentDashboard))); .finally(() => setDashboard((currentDashboard) => extend({}, currentDashboard)));
}, []); }, []);
const refreshWidget = useCallback(widget => loadWidget(widget, true), [loadWidget]); const refreshWidget = useCallback((widget) => loadWidget(widget, true), [loadWidget]);
const removeWidget = useCallback(widgetId => { const removeWidget = useCallback((widgetId) => {
setDashboard(currentDashboard => setDashboard((currentDashboard) =>
extend({}, currentDashboard, { extend({}, currentDashboard, {
widgets: currentDashboard.widgets.filter(widget => widget.id !== undefined && widget.id !== widgetId), widgets: currentDashboard.widgets.filter((widget) => widget.id !== undefined && widget.id !== widgetId),
}) })
); );
}, []); }, []);
@@ -129,11 +132,11 @@ function useDashboard(dashboardData) {
(forceRefresh = false, updatedParameters = []) => { (forceRefresh = false, updatedParameters = []) => {
const affectedWidgets = getAffectedWidgets(dashboardRef.current.widgets, updatedParameters); const affectedWidgets = getAffectedWidgets(dashboardRef.current.widgets, updatedParameters);
const loadWidgetPromises = compact( const loadWidgetPromises = compact(
affectedWidgets.map(widget => loadWidget(widget, forceRefresh).catch(error => error)) affectedWidgets.map((widget) => loadWidget(widget, forceRefresh).catch((error) => error))
); );
return Promise.all(loadWidgetPromises).then(() => { return Promise.all(loadWidgetPromises).then(() => {
const queryResults = compact(map(dashboardRef.current.widgets, widget => widget.getQueryResult())); const queryResults = compact(map(dashboardRef.current.widgets, (widget) => widget.getQueryResult()));
const updatedFilters = collectDashboardFilters(dashboardRef.current, queryResults, location.search); const updatedFilters = collectDashboardFilters(dashboardRef.current, queryResults, location.search);
setFilters(updatedFilters); setFilters(updatedFilters);
}); });
@@ -142,7 +145,7 @@ function useDashboard(dashboardData) {
); );
const refreshDashboard = useCallback( const refreshDashboard = useCallback(
updatedParameters => { (updatedParameters) => {
if (!refreshing) { if (!refreshing) {
setRefreshing(true); setRefreshing(true);
loadDashboard(true, updatedParameters).finally(() => setRefreshing(false)); loadDashboard(true, updatedParameters).finally(() => setRefreshing(false));
@@ -151,15 +154,30 @@ function useDashboard(dashboardData) {
[refreshing, loadDashboard] [refreshing, loadDashboard]
); );
const saveDashboardParameters = useCallback(() => {
const currentDashboard = dashboardRef.current;
return updateDashboard({
options: {
...currentDashboard.options,
parameters: map(globalParameters, (p) => p.toSaveableObject()),
},
}).catch((error) => {
console.error("Failed to persist parameter values:", error);
notification.error("Parameter values could not be saved. Your changes may not be persisted.");
throw error;
});
}, [globalParameters, updateDashboard]);
const archiveDashboard = useCallback(() => { const archiveDashboard = useCallback(() => {
recordEvent("archive", "dashboard", dashboard.id); recordEvent("archive", "dashboard", dashboard.id);
Dashboard.delete(dashboard).then(updatedDashboard => Dashboard.delete(dashboard).then((updatedDashboard) =>
setDashboard(currentDashboard => extend({}, currentDashboard, pick(updatedDashboard, ["is_archived"]))) setDashboard((currentDashboard) => extend({}, currentDashboard, pick(updatedDashboard, ["is_archived"])))
); );
}, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps }, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps
const showShareDashboardDialog = useCallback(() => { const showShareDashboardDialog = useCallback(() => {
const handleDialogClose = () => setDashboard(currentDashboard => extend({}, currentDashboard)); const handleDialogClose = () => setDashboard((currentDashboard) => extend({}, currentDashboard));
ShareDashboardDialog.showModal({ ShareDashboardDialog.showModal({
dashboard, dashboard,
@@ -172,8 +190,8 @@ function useDashboard(dashboardData) {
const showAddTextboxDialog = useCallback(() => { const showAddTextboxDialog = useCallback(() => {
TextboxDialog.showModal({ TextboxDialog.showModal({
isNew: true, isNew: true,
}).onClose(text => }).onClose((text) =>
dashboard.addWidget(text).then(() => setDashboard(currentDashboard => extend({}, currentDashboard))) dashboard.addWidget(text).then(() => setDashboard((currentDashboard) => extend({}, currentDashboard)))
); );
}, [dashboard]); }, [dashboard]);
@@ -185,13 +203,13 @@ function useDashboard(dashboardData) {
.addWidget(visualization, { .addWidget(visualization, {
parameterMappings: editableMappingsToParameterMappings(parameterMappings), parameterMappings: editableMappingsToParameterMappings(parameterMappings),
}) })
.then(widget => { .then((widget) => {
const widgetsToSave = [ const widgetsToSave = [
widget, widget,
...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets), ...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets),
]; ];
return Promise.all(widgetsToSave.map(w => w.save())).then(() => return Promise.all(widgetsToSave.map((w) => w.save())).then(() =>
setDashboard(currentDashboard => extend({}, currentDashboard)) setDashboard((currentDashboard) => extend({}, currentDashboard))
); );
}) })
); );
@@ -235,6 +253,7 @@ function useDashboard(dashboardData) {
setRefreshRate, setRefreshRate,
disableRefreshRate, disableRefreshRate,
...editModeHandler, ...editModeHandler,
saveDashboardParameters,
gridDisabled, gridDisabled,
setGridDisabled, setGridDisabled,
fullscreen, fullscreen,
@@ -243,6 +262,8 @@ function useDashboard(dashboardData) {
showAddTextboxDialog, showAddTextboxDialog,
showAddWidgetDialog, showAddWidgetDialog,
managePermissions, managePermissions,
isDuplicating,
duplicateDashboard,
}; };
} }

View File

@@ -0,0 +1,40 @@
import { noop, extend, pick } from "lodash";
import { useCallback, useState } from "react";
import url from "url";
import qs from "query-string";
import { Dashboard } from "@/services/dashboard";
function keepCurrentUrlParams(targetUrl) {
const currentUrlParams = qs.parse(window.location.search);
targetUrl = url.parse(targetUrl);
const targetUrlParams = qs.parse(targetUrl.search);
return url.format(
extend(pick(targetUrl, ["protocol", "auth", "host", "pathname"]), {
search: qs.stringify(extend(currentUrlParams, targetUrlParams)),
})
);
}
export default function useDuplicateDashboard(dashboard) {
const [isDuplicating, setIsDuplicating] = useState(false);
const duplicateDashboard = useCallback(() => {
// To prevent opening the same tab, name must be unique for each browser
const tabName = `duplicatedDashboardTab/${Math.random().toString()}`;
// We should open tab here because this moment is a part of user interaction;
// later browser will block such attempts
const tab = window.open("", tabName);
setIsDuplicating(true);
Dashboard.fork({ id: dashboard.id })
.then(newDashboard => {
tab.location = keepCurrentUrlParams(newDashboard.getUrl());
})
.finally(() => {
setIsDuplicating(false);
});
}, [dashboard.id]);
return [isDuplicating, isDuplicating ? noop : duplicateDashboard];
}

View File

@@ -31,7 +31,8 @@ function DeprecatedEmbedFeatureAlert() {
<Link <Link
href="https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337" href="https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337"
target="_blank" target="_blank"
rel="noopener noreferrer"> rel="noopener noreferrer"
>
Read more Read more
</Link> </Link>
. .
@@ -43,7 +44,7 @@ function DeprecatedEmbedFeatureAlert() {
function EmailNotVerifiedAlert() { function EmailNotVerifiedAlert() {
const verifyEmail = () => { const verifyEmail = () => {
axios.post("verification_email/").then(data => { axios.post("verification_email/").then((data) => {
notification.success(data.message); notification.success(data.message);
}); });
}; };
@@ -100,6 +101,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/", path: "/",
title: "Redash", title: "Redash",
render: pageProps => <Home {...pageProps} />, render: (pageProps) => <Home {...pageProps} />,
}) })
); );

View File

@@ -15,7 +15,7 @@ export function FavoriteList({ title, resource, itemUrl, emptyState }) {
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
resource resource
.favorites() .favorites({ order: "-starred_at" })
.then(({ results }) => setItems(results)) .then(({ results }) => setItems(results))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [resource]); }, [resource]);
@@ -28,7 +28,7 @@ export function FavoriteList({ title, resource, itemUrl, emptyState }) {
</div> </div>
{!isEmpty(items) && ( {!isEmpty(items) && (
<div role="list" className="list-group"> <div role="list" className="list-group">
{items.map(item => ( {items.map((item) => (
<Link key={itemUrl(item)} role="listitem" className="list-group-item" href={itemUrl(item)}> <Link key={itemUrl(item)} role="listitem" className="list-group-item" href={itemUrl(item)}>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">
<i className="fa fa-star" aria-hidden="true" /> <i className="fa fa-star" aria-hidden="true" />
@@ -61,7 +61,7 @@ export function DashboardAndQueryFavoritesList() {
<FavoriteList <FavoriteList
title="Favorite Dashboards" title="Favorite Dashboards"
resource={Dashboard} resource={Dashboard}
itemUrl={dashboard => dashboard.url} itemUrl={(dashboard) => dashboard.url}
emptyState={ emptyState={
<p> <p>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">
@@ -76,7 +76,7 @@ export function DashboardAndQueryFavoritesList() {
<FavoriteList <FavoriteList
title="Favorite Queries" title="Favorite Queries"
resource={Query} resource={Query}
itemUrl={query => `queries/${query.id}`} itemUrl={(query) => `queries/${query.id}`}
emptyState={ emptyState={
<p> <p>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef } from "react";
import cx from "classnames"; import cx from "classnames";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession"; import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
@@ -20,7 +20,7 @@ import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTab
import Layout from "@/components/layouts/ContentWithSidebar"; import Layout from "@/components/layouts/ContentWithSidebar";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import { currentUser } from "@/services/auth"; import { clientConfig, currentUser } from "@/services/auth";
import location from "@/services/location"; import location from "@/services/location";
import routes from "@/services/routes"; import routes from "@/services/routes";
@@ -95,25 +95,39 @@ function QueriesList({ controller }) {
const controllerRef = useRef(); const controllerRef = useRef();
controllerRef.current = controller; controllerRef.current = controller;
const updateSearch = useCallback(
(searchTemm) => {
controller.updateSearch(searchTemm, { isServerSideFTS: !clientConfig.multiByteSearchEnabled });
},
[controller]
);
useEffect(() => { useEffect(() => {
const unlistenLocationChanges = location.listen((unused, action) => { const unlistenLocationChanges = location.listen((unused, action) => {
const searchTerm = location.search.q || ""; const searchTerm = location.search.q || "";
if (action === "PUSH" && searchTerm !== controllerRef.current.searchTerm) { if (action === "PUSH" && searchTerm !== controllerRef.current.searchTerm) {
controllerRef.current.updateSearch(searchTerm); updateSearch(searchTerm);
} }
}); });
return () => { return () => {
unlistenLocationChanges(); unlistenLocationChanges();
}; };
}, []); }, [updateSearch]);
let usedListColumns = listColumns;
if (controller.params.currentPage === "favorites") {
usedListColumns = [
...usedListColumns,
Columns.dateTime.sortable({ title: "Starred At", field: "starred_at", width: "1%" }),
];
}
const { const {
areExtraActionsAvailable, areExtraActionsAvailable,
listColumns: tableColumns, listColumns: tableColumns,
Component: ExtraActionsComponent, Component: ExtraActionsComponent,
selectedItems, selectedItems,
} = useItemsListExtraActions(controller, listColumns, QueriesListExtraActions); } = useItemsListExtraActions(controller, usedListColumns, QueriesListExtraActions);
return ( return (
<div className="page-queries-list"> <div className="page-queries-list">
@@ -135,7 +149,7 @@ function QueriesList({ controller }) {
placeholder="Search Queries..." placeholder="Search Queries..."
label="Search queries" label="Search queries"
value={controller.searchTerm} value={controller.searchTerm}
onChange={controller.updateSearch} onChange={updateSearch}
/> />
<Sidebar.Menu items={sidebarMenu} selected={controller.params.currentPage} /> <Sidebar.Menu items={sidebarMenu} selected={controller.params.currentPage} />
<Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} showUnselectAll /> <Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} showUnselectAll />
@@ -160,14 +174,15 @@ function QueriesList({ controller }) {
orderByField={controller.orderByField} orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse} orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting} toggleSorting={controller.toggleSorting}
setSorting={controller.setSorting}
/> />
<Paginator <Paginator
showPageSizeSelect showPageSizeSelect
totalCount={controller.totalItemsCount} totalCount={controller.totalItemsCount}
pageSize={controller.itemsPerPage} pageSize={controller.itemsPerPage}
onPageSizeChange={itemsPerPage => controller.updatePagination({ itemsPerPage })} onPageSizeChange={(itemsPerPage) => controller.updatePagination({ itemsPerPage })}
page={controller.page} page={controller.page}
onChange={page => controller.updatePagination({ page })} onChange={(page) => controller.updatePagination({ page })}
/> />
</div> </div>
</React.Fragment> </React.Fragment>
@@ -196,10 +211,10 @@ const QueriesListPage = itemsList(
}[currentPage]; }[currentPage];
}, },
getItemProcessor() { getItemProcessor() {
return item => new Query(item); return (item) => new Query(item);
}, },
}), }),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true }) ({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true })
); );
routes.register( routes.register(
@@ -207,7 +222,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries", path: "/queries",
title: "Queries", title: "Queries",
render: pageProps => <QueriesListPage {...pageProps} currentPage="all" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="all" />,
}) })
); );
routes.register( routes.register(
@@ -215,7 +230,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/favorites", path: "/queries/favorites",
title: "Favorite Queries", title: "Favorite Queries",
render: pageProps => <QueriesListPage {...pageProps} currentPage="favorites" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" orderByField="starred_at" />,
}) })
); );
routes.register( routes.register(
@@ -223,7 +238,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/archive", path: "/queries/archive",
title: "Archived Queries", title: "Archived Queries",
render: pageProps => <QueriesListPage {...pageProps} currentPage="archive" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="archive" />,
}) })
); );
routes.register( routes.register(
@@ -231,6 +246,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/my", path: "/queries/my",
title: "My Queries", title: "My Queries",
render: pageProps => <QueriesListPage {...pageProps} currentPage="my" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="my" />,
}) })
); );

View File

@@ -37,9 +37,10 @@
&.active { &.active {
overflow: visible; overflow: visible;
max-height: unset !important;
.ant-input { .ant-input {
resize: vertical; resize: vertical;
max-height: 150px - 15px * 2; height: 30vh;
} }
} }
} }

View File

@@ -9,6 +9,7 @@ import QueryControlDropdown from "@/components/EditVisualizationButton/QueryCont
import EditVisualizationButton from "@/components/EditVisualizationButton"; import EditVisualizationButton from "@/components/EditVisualizationButton";
import useQueryResultData from "@/lib/useQueryResultData"; import useQueryResultData from "@/lib/useQueryResultData";
import { durationHumanize, pluralize, prettySize } from "@/lib/utils"; import { durationHumanize, pluralize, prettySize } from "@/lib/utils";
import { isUndefined } from "lodash";
import "./QueryExecutionMetadata.less"; import "./QueryExecutionMetadata.less";
@@ -51,7 +52,8 @@ export default function QueryExecutionMetadata({
"Result truncated to " + "Result truncated to " +
queryResultData.rows.length + queryResultData.rows.length +
" rows. Databricks may truncate query results that are unstably large." " rows. Databricks may truncate query results that are unstably large."
}> }
>
<WarningTwoTone twoToneColor="#FF9800" /> <WarningTwoTone twoToneColor="#FF9800" />
</Tooltip> </Tooltip>
</span> </span>
@@ -67,10 +69,9 @@ export default function QueryExecutionMetadata({
)} )}
{isQueryExecuting && <span>Running&hellip;</span>} {isQueryExecuting && <span>Running&hellip;</span>}
</span> </span>
{queryResultData.metadata.data_scanned && ( {!isUndefined(queryResultData.metadata.data_scanned) && !isQueryExecuting && (
<span className="m-l-5"> <span className="m-l-5">
Data Scanned Data Scanned <strong>{prettySize(queryResultData.metadata.data_scanned)}</strong>
<strong>{prettySize(queryResultData.metadata.data_scanned)}</strong>
</span> </span>
)} )}
</span> </span>

View File

@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import React from "react"; import React from "react";
export function QuerySourceTypeIcon(props) { export function QuerySourceTypeIcon(props) {
return <img src={`static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />; return <img src={`/static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />;
} }
QuerySourceTypeIcon.propTypes = { QuerySourceTypeIcon.propTypes = {

View File

@@ -18,7 +18,7 @@ function EmptyState({ title, message, refreshButton }) {
<div className="query-results-empty-state"> <div className="query-results-empty-state">
<div className="empty-state-content"> <div className="empty-state-content">
<div> <div>
<img src="static/images/illustrations/no-query-results.svg" alt="No Query Results Illustration" /> <img src="/static/images/illustrations/no-query-results.svg" alt="No Query Results Illustration" />
</div> </div>
<h3>{title}</h3> <h3>{title}</h3>
<div className="m-b-20">{message}</div> <div className="m-b-20">{message}</div>
@@ -40,7 +40,7 @@ EmptyState.defaultProps = {
function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) { function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) {
const handleDelete = useCallback( const handleDelete = useCallback(
e => { (e) => {
e.stopPropagation(); e.stopPropagation();
Modal.confirm({ Modal.confirm({
title: "Delete Visualization", title: "Delete Visualization",
@@ -111,7 +111,8 @@ export default function QueryVisualizationTabs({
className="add-visualization-button" className="add-visualization-button"
data-test="NewVisualization" data-test="NewVisualization"
type="link" type="link"
onClick={() => onAddVisualization()}> onClick={() => onAddVisualization()}
>
<i className="fa fa-plus" aria-hidden="true" /> <i className="fa fa-plus" aria-hidden="true" />
<span className="m-l-5 hidden-xs">Add Visualization</span> <span className="m-l-5 hidden-xs">Add Visualization</span>
</Button> </Button>
@@ -119,7 +120,7 @@ export default function QueryVisualizationTabs({
} }
const orderedVisualizations = useMemo(() => orderBy(visualizations, ["id"]), [visualizations]); const orderedVisualizations = useMemo(() => orderBy(visualizations, ["id"]), [visualizations]);
const isFirstVisualization = useCallback(visId => visId === orderedVisualizations[0].id, [orderedVisualizations]); const isFirstVisualization = useCallback((visId) => visId === orderedVisualizations[0].id, [orderedVisualizations]);
const isMobile = useMedia({ maxWidth: 768 }); const isMobile = useMedia({ maxWidth: 768 });
const [filters, setFilters] = useState([]); const [filters, setFilters] = useState([]);
@@ -132,9 +133,10 @@ export default function QueryVisualizationTabs({
data-test="QueryPageVisualizationTabs" data-test="QueryPageVisualizationTabs"
animated={false} animated={false}
tabBarGutter={0} tabBarGutter={0}
onChange={activeKey => onChangeTab(+activeKey)} onChange={(activeKey) => onChangeTab(+activeKey)}
destroyInactiveTabPane> destroyInactiveTabPane
{orderedVisualizations.map(visualization => ( >
{orderedVisualizations.map((visualization) => (
<TabPane <TabPane
key={`${visualization.id}`} key={`${visualization.id}`}
tab={ tab={
@@ -144,7 +146,8 @@ export default function QueryVisualizationTabs({
visualizationName={visualization.name} visualizationName={visualization.name}
onDelete={() => onDeleteVisualization(visualization.id)} onDelete={() => onDeleteVisualization(visualization.id)}
/> />
}> }
>
{queryResult ? ( {queryResult ? (
<VisualizationRenderer <VisualizationRenderer
visualization={visualization} visualization={visualization}

View File

@@ -1,16 +1,11 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { reduce } from "lodash";
import localOptions from "@/lib/localOptions"; import localOptions from "@/lib/localOptions";
function calculateTokensCount(schema) {
return reduce(schema, (totalLength, table) => totalLength + table.columns.length, 0);
}
export default function useAutocompleteFlags(schema) { export default function useAutocompleteFlags(schema) {
const isAvailable = useMemo(() => calculateTokensCount(schema) <= 5000, [schema]); const isAvailable = true;
const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true)); const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
const toggleAutocomplete = useCallback(state => { const toggleAutocomplete = useCallback((state) => {
setIsEnabled(state); setIsEnabled(state);
localOptions.set("liveAutocomplete", state); localOptions.set("liveAutocomplete", state);
}, []); }, []);

View File

@@ -17,14 +17,16 @@ export default function BeaconConsentSettings(props) {
Anonymous Usage Data Sharing Anonymous Usage Data Sharing
<HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" /> <HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" />
</span> </span>
}> }
>
{loading ? ( {loading ? (
<Skeleton title={{ width: 300 }} paragraph={false} active /> <Skeleton title={{ width: 300 }} paragraph={false} active />
) : ( ) : (
<Checkbox <Checkbox
name="beacon_consent" name="beacon_consent"
checked={values.beacon_consent} checked={values.beacon_consent}
onChange={e => onChange({ beacon_consent: e.target.checked })}> onChange={(e) => onChange({ beacon_consent: e.target.checked })}
>
Help Redash improve by automatically sending anonymous usage data Help Redash improve by automatically sending anonymous usage data
</Checkbox> </Checkbox>
)} )}

View File

@@ -36,6 +36,7 @@ const Alert = {
delete: data => axios.delete(`api/alerts/${data.id}`), delete: data => axios.delete(`api/alerts/${data.id}`),
mute: data => axios.post(`api/alerts/${data.id}/mute`), mute: data => axios.post(`api/alerts/${data.id}/mute`),
unmute: data => axios.delete(`api/alerts/${data.id}/mute`), unmute: data => axios.delete(`api/alerts/${data.id}/mute`),
evaluate: data => axios.post(`api/alerts/${data.id}/eval`),
}; };
export default Alert; export default Alert;

View File

@@ -10,9 +10,9 @@ export const urlForDashboard = ({ id, slug }) => `dashboards/${id}-${slug}`;
export function collectDashboardFilters(dashboard, queryResults, urlParams) { export function collectDashboardFilters(dashboard, queryResults, urlParams) {
const filters = {}; const filters = {};
_.each(queryResults, queryResult => { _.each(queryResults, (queryResult) => {
const queryFilters = queryResult && queryResult.getFilters ? queryResult.getFilters() : []; const queryFilters = queryResult && queryResult.getFilters ? queryResult.getFilters() : [];
_.each(queryFilters, queryFilter => { _.each(queryFilters, (queryFilter) => {
const hasQueryStringValue = _.has(urlParams, queryFilter.name); const hasQueryStringValue = _.has(urlParams, queryFilter.name);
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) { if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
@@ -44,7 +44,7 @@ function prepareWidgetsForDashboard(widgets) {
const defaultWidgetSizeY = const defaultWidgetSizeY =
Math.max( Math.max(
_.chain(widgets) _.chain(widgets)
.map(w => w.options.position.sizeY) .map((w) => w.options.position.sizeY)
.max() .max()
.value(), .value(),
20 20
@@ -55,11 +55,11 @@ function prepareWidgetsForDashboard(widgets) {
// 2. update position of widgets in each row - place it right below // 2. update position of widgets in each row - place it right below
// biggest widget from previous row // biggest widget from previous row
_.chain(widgets) _.chain(widgets)
.sortBy(widget => widget.options.position.row) .sortBy((widget) => widget.options.position.row)
.groupBy(widget => widget.options.position.row) .groupBy((widget) => widget.options.position.row)
.reduce((row, widgetsAtRow) => { .reduce((row, widgetsAtRow) => {
let height = 1; let height = 1;
_.each(widgetsAtRow, widget => { _.each(widgetsAtRow, (widget) => {
height = Math.max( height = Math.max(
height, height,
widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY
@@ -74,8 +74,8 @@ function prepareWidgetsForDashboard(widgets) {
.value(); .value();
// Sort widgets by updated column and row value // Sort widgets by updated column and row value
widgets = _.sortBy(widgets, widget => widget.options.position.col); widgets = _.sortBy(widgets, (widget) => widget.options.position.col);
widgets = _.sortBy(widgets, widget => widget.options.position.row); widgets = _.sortBy(widgets, (widget) => widget.options.position.row);
return widgets; return widgets;
} }
@@ -85,7 +85,7 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
// Find first free row for each column // Find first free row for each column
const bottomLine = _.chain(existingWidgets) const bottomLine = _.chain(existingWidgets)
.map(w => { .map((w) => {
const options = _.extend({}, w.options); const options = _.extend({}, w.options);
const position = _.extend({ row: 0, sizeY: 0 }, options.position); const position = _.extend({ row: 0, sizeY: 0 }, options.position);
return { return {
@@ -97,21 +97,24 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
height: position.sizeY, height: position.sizeY,
}; };
}) })
.reduce((result, item) => { .reduce(
const from = Math.max(item.left, 0); (result, item) => {
const to = Math.min(item.right, result.length + 1); const from = Math.max(item.left, 0);
for (let i = from; i < to; i += 1) { const to = Math.min(item.right, result.length + 1);
result[i] = Math.max(result[i], item.bottom); for (let i = from; i < to; i += 1) {
} result[i] = Math.max(result[i], item.bottom);
return result; }
}, _.map(new Array(dashboardGridOptions.columns), _.constant(0))) return result;
},
_.map(new Array(dashboardGridOptions.columns), _.constant(0))
)
.value(); .value();
// Go through columns, pick them by count necessary to hold new block, // Go through columns, pick them by count necessary to hold new block,
// and calculate bottom-most free row per group. // and calculate bottom-most free row per group.
// Choose group with the top-most free row (comparing to other groups) // Choose group with the top-most free row (comparing to other groups)
return _.chain(_.range(0, dashboardGridOptions.columns - width + 1)) return _.chain(_.range(0, dashboardGridOptions.columns - width + 1))
.map(col => ({ .map((col) => ({
col, col,
row: _.chain(bottomLine) row: _.chain(bottomLine)
.slice(col, col + width) .slice(col, col + width)
@@ -126,14 +129,14 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
export function Dashboard(dashboard) { export function Dashboard(dashboard) {
_.extend(this, dashboard); _.extend(this, dashboard);
Object.defineProperty(this, "url", { Object.defineProperty(this, "url", {
get: function() { get: function () {
return urlForDashboard(this); return urlForDashboard(this);
}, },
}); });
} }
function prepareDashboardWidgets(widgets) { function prepareDashboardWidgets(widgets) {
return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget))); return prepareWidgetsForDashboard(_.map(widgets, (widget) => new Widget(widget)));
} }
function transformSingle(dashboard) { function transformSingle(dashboard) {
@@ -154,7 +157,7 @@ function transformResponse(data) {
return data; return data;
} }
const saveOrCreateUrl = data => (data.id ? `api/dashboards/${data.id}` : "api/dashboards"); const saveOrCreateUrl = (data) => (data.id ? `api/dashboards/${data.id}` : "api/dashboards");
const DashboardService = { const DashboardService = {
get: ({ id, slug }) => { get: ({ id, slug }) => {
const params = {}; const params = {};
@@ -164,14 +167,15 @@ const DashboardService = {
return axios.get(`api/dashboards/${id || slug}`, { params }).then(transformResponse); return axios.get(`api/dashboards/${id || slug}`, { params }).then(transformResponse);
}, },
getByToken: ({ token }) => axios.get(`api/dashboards/public/${token}`).then(transformResponse), getByToken: ({ token }) => axios.get(`api/dashboards/public/${token}`).then(transformResponse),
save: data => axios.post(saveOrCreateUrl(data), data).then(transformResponse), save: (data) => axios.post(saveOrCreateUrl(data), data).then(transformResponse),
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse), delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
query: params => axios.get("api/dashboards", { params }).then(transformResponse), query: (params) => axios.get("api/dashboards", { params }).then(transformResponse),
recent: params => axios.get("api/dashboards/recent", { params }).then(transformResponse), recent: (params) => axios.get("api/dashboards/recent", { params }).then(transformResponse),
myDashboards: params => axios.get("api/dashboards/my", { params }).then(transformResponse), myDashboards: (params) => axios.get("api/dashboards/my", { params }).then(transformResponse),
favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse), favorites: (params) => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`), favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`), unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
fork: ({ id }) => axios.post(`api/dashboards/${id}/fork`, { id }).then(transformResponse),
}; };
_.extend(Dashboard, DashboardService); _.extend(Dashboard, DashboardService);
@@ -186,13 +190,13 @@ Dashboard.prototype.canEdit = function canEdit() {
Dashboard.prototype.getParametersDefs = function getParametersDefs() { Dashboard.prototype.getParametersDefs = function getParametersDefs() {
const globalParams = {}; const globalParams = {};
const queryParams = location.search; const queryParams = location.search;
_.each(this.widgets, widget => { _.each(this.widgets, (widget) => {
if (widget.getQuery()) { if (widget.getQuery()) {
const mappings = widget.getParameterMappings(); const mappings = widget.getParameterMappings();
widget widget
.getQuery() .getQuery()
.getParametersDefs(false) .getParametersDefs(false)
.forEach(param => { .forEach((param) => {
const mapping = mappings[param.name]; const mapping = mappings[param.name];
if (mapping.type === Widget.MappingType.DashboardLevel) { if (mapping.type === Widget.MappingType.DashboardLevel) {
// create global param // create global param
@@ -209,15 +213,19 @@ Dashboard.prototype.getParametersDefs = function getParametersDefs() {
}); });
} }
}); });
const mergedValues = {
..._.mapValues(globalParams, (p) => p.value),
...Object.fromEntries((this.options.parameters || []).map((param) => [param.name, param.value])),
};
const resultingGlobalParams = _.values( const resultingGlobalParams = _.values(
_.each(globalParams, param => { _.each(globalParams, (param) => {
param.setValue(param.value); // apply global param value to all locals param.setValue(mergedValues[param.name]); // apply merged value
param.fromUrlParams(queryParams); // try to initialize from url (may do nothing) param.fromUrlParams(queryParams); // allow param-specific parsing logic
}) })
); );
// order dashboard params using paramOrder // order dashboard params using paramOrder
return _.sortBy(resultingGlobalParams, param => return _.sortBy(resultingGlobalParams, (param) =>
_.includes(this.options.globalParamOrder, param.name) _.includes(this.options.globalParamOrder, param.name)
? _.indexOf(this.options.globalParamOrder, param.name) ? _.indexOf(this.options.globalParamOrder, param.name)
: _.size(this.options.globalParamOrder) : _.size(this.options.globalParamOrder)
@@ -265,3 +273,7 @@ Dashboard.prototype.favorite = function favorite() {
Dashboard.prototype.unfavorite = function unfavorite() { Dashboard.prototype.unfavorite = function unfavorite() {
return Dashboard.unfavorite(this); return Dashboard.unfavorite(this);
}; };
Dashboard.prototype.getUrl = function getUrl() {
return urlForDashboard(this);
};

View File

@@ -4,19 +4,19 @@ import { fetchDataFromJob } from "@/services/query-result";
export const SCHEMA_NOT_SUPPORTED = 1; export const SCHEMA_NOT_SUPPORTED = 1;
export const SCHEMA_LOAD_ERROR = 2; export const SCHEMA_LOAD_ERROR = 2;
export const IMG_ROOT = "static/images/db-logos"; export const IMG_ROOT = "/static/images/db-logos";
function mapSchemaColumnsToObject(columns) { function mapSchemaColumnsToObject(columns) {
return map(columns, column => (isObject(column) ? column : { name: column })); return map(columns, (column) => (isObject(column) ? column : { name: column }));
} }
const DataSource = { const DataSource = {
query: () => axios.get("api/data_sources"), query: () => axios.get("api/data_sources"),
get: ({ id }) => axios.get(`api/data_sources/${id}`), get: ({ id }) => axios.get(`api/data_sources/${id}`),
types: () => axios.get("api/data_sources/types"), types: () => axios.get("api/data_sources/types"),
create: data => axios.post(`api/data_sources`, data), create: (data) => axios.post(`api/data_sources`, data),
save: data => axios.post(`api/data_sources/${data.id}`, data), save: (data) => axios.post(`api/data_sources/${data.id}`, data),
test: data => axios.post(`api/data_sources/${data.id}/test`), test: (data) => axios.post(`api/data_sources/${data.id}/test`),
delete: ({ id }) => axios.delete(`api/data_sources/${id}`), delete: ({ id }) => axios.delete(`api/data_sources/${id}`),
fetchSchema: (data, refresh = false) => { fetchSchema: (data, refresh = false) => {
const params = {}; const params = {};
@@ -27,15 +27,15 @@ const DataSource = {
return axios return axios
.get(`api/data_sources/${data.id}/schema`, { params }) .get(`api/data_sources/${data.id}/schema`, { params })
.then(data => { .then((data) => {
if (has(data, "job")) { if (has(data, "job")) {
return fetchDataFromJob(data.job.id).catch(error => return fetchDataFromJob(data.job.id).catch((error) =>
error.code === SCHEMA_NOT_SUPPORTED ? [] : Promise.reject(new Error(data.job.error)) error.code === SCHEMA_NOT_SUPPORTED ? [] : Promise.reject(new Error(data.job.error))
); );
} }
return has(data, "schema") ? data.schema : Promise.reject(); return has(data, "schema") ? data.schema : Promise.reject();
}) })
.then(tables => map(tables, table => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) }))); .then((tables) => map(tables, (table) => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) })));
}, },
}; };

View File

@@ -9,7 +9,7 @@ function normalizeLocation(rawLocation) {
const result = {}; const result = {};
result.path = pathname; result.path = pathname;
result.search = mapValues(qs.parse(search), value => (isNil(value) ? true : value)); result.search = mapValues(qs.parse(search), (value) => (isNil(value) ? true : value));
result.hash = trimStart(hash, "#"); result.hash = trimStart(hash, "#");
result.url = `${pathname}${search}${hash}`; result.url = `${pathname}${search}${hash}`;
@@ -27,7 +27,7 @@ const location = {
confirmChange(handler) { confirmChange(handler) {
if (isFunction(handler)) { if (isFunction(handler)) {
return history.block(nextLocation => { return history.block((nextLocation) => {
return handler(normalizeLocation(nextLocation), location); return handler(normalizeLocation(nextLocation), location);
}); });
} else { } else {
@@ -60,12 +60,18 @@ const location = {
// serialize search and keep existing search parameters (!) // serialize search and keep existing search parameters (!)
if (isObject(newLocation.search)) { if (isObject(newLocation.search)) {
newLocation.search = omitBy(extend({}, location.search, newLocation.search), isNil); newLocation.search = omitBy(extend({}, location.search, newLocation.search), isNil);
newLocation.search = mapValues(newLocation.search, value => (value === true ? null : value)); newLocation.search = mapValues(newLocation.search, (value) => (value === true ? null : value));
newLocation.search = qs.stringify(newLocation.search); newLocation.search = qs.stringify(newLocation.search);
} }
} }
if (replace) { if (replace) {
history.replace(newLocation); if (
newLocation.pathname !== location.path ||
newLocation.search !== qs.stringify(location.search) ||
newLocation.hash !== location.hash
) {
history.replace(newLocation);
}
} else { } else {
history.push(newLocation); history.push(newLocation);
} }

View File

@@ -61,7 +61,7 @@ class DateParameter extends Parameter {
return value; return value;
} }
const normalizedValue = moment(value); const normalizedValue = moment(value, moment.ISO_8601, true);
return normalizedValue.isValid() ? normalizedValue : null; return normalizedValue.isValid() ? normalizedValue : null;
} }

View File

@@ -17,7 +17,9 @@ const DYNAMIC_PREFIX = "d_";
* @param now {function(): moment.Moment=} moment - defaults to now * @param now {function(): moment.Moment=} moment - defaults to now
* @returns {function(withNow: boolean): [moment.Moment, moment.Moment|undefined]} * @returns {function(withNow: boolean): [moment.Moment, moment.Moment|undefined]}
*/ */
const untilNow = (from, now = () => moment()) => (withNow = true) => [from(), withNow ? now() : undefined]; const untilNow =
(from, now = () => moment()) =>
(withNow = true) => [from(), withNow ? now() : undefined];
const DYNAMIC_DATE_RANGES = { const DYNAMIC_DATE_RANGES = {
today: { today: {
@@ -26,14 +28,7 @@ const DYNAMIC_DATE_RANGES = {
}, },
yesterday: { yesterday: {
name: "Yesterday", name: "Yesterday",
value: () => [ value: () => [moment().subtract(1, "day").startOf("day"), moment().subtract(1, "day").endOf("day")],
moment()
.subtract(1, "day")
.startOf("day"),
moment()
.subtract(1, "day")
.endOf("day"),
],
}, },
this_week: { this_week: {
name: "This week", name: "This week",
@@ -49,36 +44,15 @@ const DYNAMIC_DATE_RANGES = {
}, },
last_week: { last_week: {
name: "Last week", name: "Last week",
value: () => [ value: () => [moment().subtract(1, "week").startOf("week"), moment().subtract(1, "week").endOf("week")],
moment()
.subtract(1, "week")
.startOf("week"),
moment()
.subtract(1, "week")
.endOf("week"),
],
}, },
last_month: { last_month: {
name: "Last month", name: "Last month",
value: () => [ value: () => [moment().subtract(1, "month").startOf("month"), moment().subtract(1, "month").endOf("month")],
moment()
.subtract(1, "month")
.startOf("month"),
moment()
.subtract(1, "month")
.endOf("month"),
],
}, },
last_year: { last_year: {
name: "Last year", name: "Last year",
value: () => [ value: () => [moment().subtract(1, "year").startOf("year"), moment().subtract(1, "year").endOf("year")],
moment()
.subtract(1, "year")
.startOf("year"),
moment()
.subtract(1, "year")
.endOf("year"),
],
}, },
last_hour: { last_hour: {
name: "Last hour", name: "Last hour",
@@ -94,63 +68,31 @@ const DYNAMIC_DATE_RANGES = {
}, },
last_7_days: { last_7_days: {
name: "Last 7 days", name: "Last 7 days",
value: untilNow( value: untilNow(() => moment().subtract(7, "days").startOf("day")),
() =>
moment()
.subtract(7, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_14_days: { last_14_days: {
name: "Last 14 days", name: "Last 14 days",
value: untilNow( value: untilNow(() => moment().subtract(14, "days").startOf("day")),
() =>
moment()
.subtract(14, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_30_days: { last_30_days: {
name: "Last 30 days", name: "Last 30 days",
value: untilNow( value: untilNow(() => moment().subtract(30, "days").startOf("day")),
() =>
moment()
.subtract(30, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_60_days: { last_60_days: {
name: "Last 60 days", name: "Last 60 days",
value: untilNow( value: untilNow(() => moment().subtract(60, "days").startOf("day")),
() =>
moment()
.subtract(60, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_90_days: { last_90_days: {
name: "Last 90 days", name: "Last 90 days",
value: untilNow( value: untilNow(() => moment().subtract(90, "days").startOf("day")),
() =>
moment()
.subtract(90, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_12_months: { last_12_months: {
name: "Last 12 months", name: "Last 12 months",
value: untilNow( value: untilNow(() => moment().subtract(12, "months").startOf("day")),
() => },
moment() last_10_years: {
.subtract(12, "months") name: "Last 10 years",
.startOf("day"), value: untilNow(() => moment().subtract(10, "years").startOf("day")),
() => moment().endOf("day")
),
}, },
}; };
@@ -164,7 +106,7 @@ export function isDynamicDateRangeString(value) {
} }
export function getDynamicDateRangeStringFromName(dynamicRangeName) { export function getDynamicDateRangeStringFromName(dynamicRangeName) {
const key = findKey(DYNAMIC_DATE_RANGES, range => range.name === dynamicRangeName); const key = findKey(DYNAMIC_DATE_RANGES, (range) => range.name === dynamicRangeName);
return key ? DYNAMIC_PREFIX + key : undefined; return key ? DYNAMIC_PREFIX + key : undefined;
} }
@@ -233,7 +175,7 @@ class DateRangeParameter extends Parameter {
getExecutionValue() { getExecutionValue() {
if (this.hasDynamicValue) { if (this.hasDynamicValue) {
const format = date => date.format(DATETIME_FORMATS[this.type]); const format = (date) => date.format(DATETIME_FORMATS[this.type]);
const [start, end] = this.normalizedValue.value().map(format); const [start, end] = this.normalizedValue.value().map(format);
return { start, end }; return { start, end };
} }

View File

@@ -58,7 +58,7 @@ class Parameter {
updateLocals() { updateLocals() {
if (isArray(this.locals)) { if (isArray(this.locals)) {
each(this.locals, local => { each(this.locals, (local) => {
local.setValue(this.value); local.setValue(this.value);
}); });
} }
@@ -117,7 +117,7 @@ class Parameter {
/** Get a saveable version of the Parameter by omitting unnecessary props */ /** Get a saveable version of the Parameter by omitting unnecessary props */
toSaveableObject() { toSaveableObject() {
return omit(this, ["$$value", "urlPrefix", "pendingValue", "parentQueryId"]); return omit(this, ["$$value", "urlPrefix", "pendingValue", "parentQueryId", "locals"]);
} }
} }

View File

@@ -0,0 +1,29 @@
import { toString, isNull } from "lodash";
import Parameter from "./Parameter";
class TextPatternParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.regex = parameter.regex;
this.setValue(parameter.value);
}
// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
const normalizedValue = toString(value);
if (isNull(normalizedValue)) {
return null;
}
var re = new RegExp(this.regex);
if (re !== null) {
if (re.test(normalizedValue)) {
return normalizedValue;
}
}
return null;
}
}
export default TextPatternParameter;

View File

@@ -5,6 +5,7 @@ import EnumParameter from "./EnumParameter";
import QueryBasedDropdownParameter from "./QueryBasedDropdownParameter"; import QueryBasedDropdownParameter from "./QueryBasedDropdownParameter";
import DateParameter from "./DateParameter"; import DateParameter from "./DateParameter";
import DateRangeParameter from "./DateRangeParameter"; import DateRangeParameter from "./DateRangeParameter";
import TextPatternParameter from "./TextPatternParameter";
function createParameter(param, parentQueryId) { function createParameter(param, parentQueryId) {
switch (param.type) { switch (param.type) {
@@ -22,6 +23,8 @@ function createParameter(param, parentQueryId) {
case "datetime-range": case "datetime-range":
case "datetime-range-with-seconds": case "datetime-range-with-seconds":
return new DateRangeParameter(param, parentQueryId); return new DateRangeParameter(param, parentQueryId);
case "text-pattern":
return new TextPatternParameter({ ...param, type: "text-pattern" }, parentQueryId);
default: default:
return new TextParameter({ ...param, type: "text" }, parentQueryId); return new TextParameter({ ...param, type: "text" }, parentQueryId);
} }
@@ -34,6 +37,7 @@ function cloneParameter(param) {
export { export {
Parameter, Parameter,
TextParameter, TextParameter,
TextPatternParameter,
NumberParameter, NumberParameter,
EnumParameter, EnumParameter,
QueryBasedDropdownParameter, QueryBasedDropdownParameter,

View File

@@ -1,6 +1,7 @@
import { import {
createParameter, createParameter,
TextParameter, TextParameter,
TextPatternParameter,
NumberParameter, NumberParameter,
EnumParameter, EnumParameter,
QueryBasedDropdownParameter, QueryBasedDropdownParameter,
@@ -12,6 +13,7 @@ describe("Parameter", () => {
describe("create", () => { describe("create", () => {
const parameterTypes = [ const parameterTypes = [
["text", TextParameter], ["text", TextParameter],
["text-pattern", TextPatternParameter],
["number", NumberParameter], ["number", NumberParameter],
["enum", EnumParameter], ["enum", EnumParameter],
["query", QueryBasedDropdownParameter], ["query", QueryBasedDropdownParameter],

View File

@@ -0,0 +1,21 @@
import { createParameter } from "..";
describe("TextPatternParameter", () => {
let param;
beforeEach(() => {
param = createParameter({ name: "param", title: "Param", type: "text-pattern", regex: "a+" });
});
describe("noramlizeValue", () => {
test("converts matching strings", () => {
const normalizedValue = param.normalizeValue("art");
expect(normalizedValue).toBe("art");
});
test("returns null when string does not match pattern", () => {
const normalizedValue = param.normalizeValue("brt");
expect(normalizedValue).toBeNull();
});
});
});

View File

@@ -9,7 +9,7 @@ const logger = debug("redash:services:QueryResult");
const filterTypes = ["filter", "multi-filter", "multiFilter"]; const filterTypes = ["filter", "multi-filter", "multiFilter"];
function defer() { function defer() {
const result = { onStatusChange: status => {} }; const result = { onStatusChange: (status) => {} };
result.promise = new Promise((resolve, reject) => { result.promise = new Promise((resolve, reject) => {
result.resolve = resolve; result.resolve = resolve;
result.reject = reject; result.reject = reject;
@@ -40,13 +40,13 @@ function getColumnNameWithoutType(column) {
} }
function getColumnFriendlyName(column) { function getColumnFriendlyName(column) {
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, a => a.toUpperCase()); return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, (a) => a.toUpperCase());
} }
const createOrSaveUrl = data => (data.id ? `api/query_results/${data.id}` : "api/query_results"); const createOrSaveUrl = (data) => (data.id ? `api/query_results/${data.id}` : "api/query_results");
const QueryResultResource = { const QueryResultResource = {
get: ({ id }) => axios.get(`api/query_results/${id}`), get: ({ id }) => axios.get(`api/query_results/${id}`),
post: data => axios.post(createOrSaveUrl(data), data), post: (data) => axios.post(createOrSaveUrl(data), data),
}; };
export const ExecutionStatus = { export const ExecutionStatus = {
@@ -97,11 +97,11 @@ function handleErrorResponse(queryResult, error) {
} }
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
export function fetchDataFromJob(jobId, interval = 1000) { export function fetchDataFromJob(jobId, interval = 1000) {
return axios.get(`api/jobs/${jobId}`).then(data => { return axios.get(`api/jobs/${jobId}`).then((data) => {
const status = statuses[data.job.status]; const status = statuses[data.job.status];
if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) { if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) {
return sleep(interval).then(() => fetchDataFromJob(data.job.id)); return sleep(interval).then(() => fetchDataFromJob(data.job.id));
@@ -114,7 +114,7 @@ export function fetchDataFromJob(jobId, interval = 1000) {
} }
export function isDateTime(v) { export function isDateTime(v) {
return isString(v) && moment(v).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v); return isString(v) && moment(v, moment.ISO_8601, true).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v);
} }
class QueryResult { class QueryResult {
@@ -146,7 +146,7 @@ class QueryResult {
// TODO: we should stop manipulating incoming data, and switch to relaying // TODO: we should stop manipulating incoming data, and switch to relaying
// on the column type set by the backend. This logic is prone to errors, // on the column type set by the backend. This logic is prone to errors,
// and better be removed. Kept for now, for backward compatability. // and better be removed. Kept for now, for backward compatability.
each(this.query_result.data.rows, row => { each(this.query_result.data.rows, (row) => {
forOwn(row, (v, k) => { forOwn(row, (v, k) => {
let newType = null; let newType = null;
if (isNumber(v)) { if (isNumber(v)) {
@@ -173,7 +173,7 @@ class QueryResult {
}); });
}); });
each(this.query_result.data.columns, column => { each(this.query_result.data.columns, (column) => {
column.name = "" + column.name; column.name = "" + column.name;
if (columnTypes[column.name]) { if (columnTypes[column.name]) {
if (column.type == null || column.type === "string") { if (column.type == null || column.type === "string") {
@@ -265,14 +265,14 @@ class QueryResult {
getColumnNames() { getColumnNames() {
if (this.columnNames === undefined && this.query_result.data) { if (this.columnNames === undefined && this.query_result.data) {
this.columnNames = this.query_result.data.columns.map(v => v.name); this.columnNames = this.query_result.data.columns.map((v) => v.name);
} }
return this.columnNames; return this.columnNames;
} }
getColumnFriendlyNames() { getColumnFriendlyNames() {
return this.getColumnNames().map(col => getColumnFriendlyName(col)); return this.getColumnNames().map((col) => getColumnFriendlyName(col));
} }
getTruncated() { getTruncated() {
@@ -286,7 +286,7 @@ class QueryResult {
const filters = []; const filters = [];
this.getColumns().forEach(col => { this.getColumns().forEach((col) => {
const name = col.name; const name = col.name;
const type = name.split("::")[1] || name.split("__")[1]; const type = name.split("::")[1] || name.split("__")[1];
if (includes(filterTypes, type)) { if (includes(filterTypes, type)) {
@@ -302,8 +302,8 @@ class QueryResult {
} }
}, this); }, this);
this.getRawData().forEach(row => { this.getRawData().forEach((row) => {
filters.forEach(filter => { filters.forEach((filter) => {
filter.values.push(row[filter.name]); filter.values.push(row[filter.name]);
if (filter.values.length === 1) { if (filter.values.length === 1) {
if (filter.multiple) { if (filter.multiple) {
@@ -315,8 +315,8 @@ class QueryResult {
}); });
}); });
filters.forEach(filter => { filters.forEach((filter) => {
filter.values = uniqBy(filter.values, v => { filter.values = uniqBy(filter.values, (v) => {
if (moment.isMoment(v)) { if (moment.isMoment(v)) {
return v.unix(); return v.unix();
} }
@@ -345,12 +345,12 @@ class QueryResult {
axios axios
.get(`api/queries/${queryId}/results/${id}.json`) .get(`api/queries/${queryId}/results/${id}.json`)
.then(response => { .then((response) => {
// Success handler // Success handler
queryResult.isLoadingResult = false; queryResult.isLoadingResult = false;
queryResult.update(response); queryResult.update(response);
}) })
.catch(error => { .catch((error) => {
// Error handler // Error handler
queryResult.isLoadingResult = false; queryResult.isLoadingResult = false;
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
@@ -362,10 +362,10 @@ class QueryResult {
loadLatestCachedResult(queryId, parameters) { loadLatestCachedResult(queryId, parameters) {
axios axios
.post(`api/queries/${queryId}/results`, { queryId, parameters }) .post(`api/queries/${queryId}/results`, { queryId, parameters })
.then(response => { .then((response) => {
this.update(response); this.update(response);
}) })
.catch(error => { .catch((error) => {
handleErrorResponse(this, error); handleErrorResponse(this, error);
}); });
} }
@@ -375,11 +375,11 @@ class QueryResult {
this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT); this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT);
QueryResultResource.get({ id: this.job.query_result_id }) QueryResultResource.get({ id: this.job.query_result_id })
.then(response => { .then((response) => {
this.update(response); this.update(response);
this.isLoadingResult = false; this.isLoadingResult = false;
}) })
.catch(error => { .catch((error) => {
if (tryCount === undefined) { if (tryCount === undefined) {
tryCount = 0; tryCount = 0;
} }
@@ -394,9 +394,12 @@ class QueryResult {
}); });
this.isLoadingResult = false; this.isLoadingResult = false;
} else { } else {
setTimeout(() => { setTimeout(
this.loadResult(tryCount + 1); () => {
}, 1000 * Math.pow(2, tryCount)); this.loadResult(tryCount + 1);
},
1000 * Math.pow(2, tryCount)
);
} }
}); });
} }
@@ -410,19 +413,26 @@ class QueryResult {
: axios.get(`api/queries/${query}/jobs/${this.job.id}`); : axios.get(`api/queries/${query}/jobs/${this.job.id}`);
request request
.then(jobResponse => { .then((jobResponse) => {
this.update(jobResponse); this.update(jobResponse);
if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") { if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") {
loadResult(); loadResult();
} else if (this.getStatus() !== "failed") { } else if (this.getStatus() !== "failed") {
const waitTime = tryNumber > 10 ? 3000 : 500; let waitTime;
if (tryNumber <= 10) {
waitTime = 500;
} else if (tryNumber <= 50) {
waitTime = 1000;
} else {
waitTime = 3000;
}
setTimeout(() => { setTimeout(() => {
this.refreshStatus(query, parameters, tryNumber + 1); this.refreshStatus(query, parameters, tryNumber + 1);
}, waitTime); }, waitTime);
} }
}) })
.catch(error => { .catch((error) => {
logger("Connection error", error); logger("Connection error", error);
// TODO: use QueryResultError, or better yet: exception/reject of promise. // TODO: use QueryResultError, or better yet: exception/reject of promise.
this.update({ this.update({
@@ -451,14 +461,14 @@ class QueryResult {
axios axios
.post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge }) .post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge })
.then(response => { .then((response) => {
queryResult.update(response); queryResult.update(response);
if ("job" in response) { if ("job" in response) {
queryResult.refreshStatus(id, parameters); queryResult.refreshStatus(id, parameters);
} }
}) })
.catch(error => { .catch((error) => {
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}); });
@@ -481,14 +491,14 @@ class QueryResult {
} }
QueryResultResource.post(params) QueryResultResource.post(params)
.then(response => { .then((response) => {
queryResult.update(response); queryResult.update(response);
if ("job" in response) { if ("job" in response) {
queryResult.refreshStatus(query, parameters); queryResult.refreshStatus(query, parameters);
} }
}) })
.catch(error => { .catch((error) => {
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}); });

View File

@@ -1,6 +1,5 @@
/* eslint-disable import/no-extraneous-dependencies, no-console */ /* eslint-disable import/no-extraneous-dependencies, no-console */
const { find } = require("lodash"); const { find } = require("lodash");
const atob = require("atob");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const { get, post } = require("request").defaults({ jar: true }); const { get, post } = require("request").defaults({ jar: true });
const { seedData } = require("./seed-data"); const { seedData } = require("./seed-data");
@@ -44,44 +43,32 @@ function seedDatabase(seedValues) {
function buildServer() { function buildServer() {
console.log("Building the server..."); console.log("Building the server...");
execSync("docker-compose -p cypress build", { stdio: "inherit" }); execSync("docker compose -p cypress build", { stdio: "inherit" });
} }
function startServer() { function startServer() {
console.log("Starting the server..."); console.log("Starting the server...");
execSync("docker-compose -p cypress up -d", { stdio: "inherit" }); execSync("docker compose -p cypress up -d", { stdio: "inherit" });
execSync("docker-compose -p cypress run server create_db", { stdio: "inherit" }); execSync("docker compose -p cypress run server create_db", { stdio: "inherit" });
} }
function stopServer() { function stopServer() {
console.log("Stopping the server..."); console.log("Stopping the server...");
execSync("docker-compose -p cypress down", { stdio: "inherit" }); execSync("docker compose -p cypress down", { stdio: "inherit" });
} }
function runCypressCI() { function runCypressCI() {
const { const {
PERCY_TOKEN_ENCODED,
CYPRESS_PROJECT_ID_ENCODED,
CYPRESS_RECORD_KEY_ENCODED,
GITHUB_REPOSITORY, GITHUB_REPOSITORY,
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
} = process.env; } = process.env;
if (GITHUB_REPOSITORY === "getredash/redash") { if (GITHUB_REPOSITORY === "getredash/redash" && process.env.CYPRESS_RECORD_KEY) {
if (PERCY_TOKEN_ENCODED) {
process.env.PERCY_TOKEN = atob(`${PERCY_TOKEN_ENCODED}`);
}
if (CYPRESS_PROJECT_ID_ENCODED) {
process.env.CYPRESS_PROJECT_ID = atob(`${CYPRESS_PROJECT_ID_ENCODED}`);
}
if (CYPRESS_RECORD_KEY_ENCODED) {
process.env.CYPRESS_RECORD_KEY = atob(`${CYPRESS_RECORD_KEY_ENCODED}`);
}
process.env.CYPRESS_OPTIONS = "--record"; process.env.CYPRESS_OPTIONS = "--record";
} }
execSync( execSync(
"COMMIT_INFO_MESSAGE=$(git show -s --format=%s) docker-compose run --name cypress cypress ./node_modules/.bin/percy exec -t 300 -- ./node_modules/.bin/cypress run $CYPRESS_OPTIONS", "COMMIT_INFO_MESSAGE=$(git show -s --format=%s) docker compose run --name cypress cypress ./node_modules/.bin/percy exec -t 300 -- ./node_modules/.bin/cypress run $CYPRESS_OPTIONS",
{ stdio: "inherit" } { stdio: "inherit" }
); );
} }

View File

@@ -23,7 +23,7 @@ describe("Dashboard", () => {
cy.getByTestId("DashboardSaveButton").click(); cy.getByTestId("DashboardSaveButton").click();
}); });
cy.wait("@NewDashboard").then(xhr => { cy.wait("@NewDashboard").then((xhr) => {
const id = Cypress._.get(xhr, "response.body.id"); const id = Cypress._.get(xhr, "response.body.id");
assert.isDefined(id, "Dashboard api call returns id"); assert.isDefined(id, "Dashboard api call returns id");
@@ -40,13 +40,9 @@ describe("Dashboard", () => {
cy.getByTestId("DashboardMoreButton").click(); cy.getByTestId("DashboardMoreButton").click();
cy.getByTestId("DashboardMoreButtonMenu") cy.getByTestId("DashboardMoreButtonMenu").contains("Archive").click();
.contains("Archive")
.click();
cy.get(".ant-modal .ant-btn") cy.get(".ant-modal .ant-btn").contains("Archive").click({ force: true });
.contains("Archive")
.click({ force: true });
cy.get(".label-tag-archived").should("exist"); cy.get(".label-tag-archived").should("exist");
cy.visit("/dashboards"); cy.visit("/dashboards");
@@ -60,7 +56,7 @@ describe("Dashboard", () => {
cy.server(); cy.server();
cy.route("GET", "**/api/dashboards/*").as("LoadDashboard"); cy.route("GET", "**/api/dashboards/*").as("LoadDashboard");
cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => { cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => {
[`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach(url => { [`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach((url) => {
cy.visit(url); cy.visit(url);
cy.wait("@LoadDashboard"); cy.wait("@LoadDashboard");
cy.getByTestId(`DashboardId${id}Container`).should("exist"); cy.getByTestId(`DashboardId${id}Container`).should("exist");
@@ -72,7 +68,7 @@ describe("Dashboard", () => {
}); });
context("viewport width is at 800px", () => { context("viewport width is at 800px", () => {
before(function() { before(function () {
cy.login(); cy.login();
cy.createDashboard("Foo Bar") cy.createDashboard("Foo Bar")
.then(({ id }) => { .then(({ id }) => {
@@ -80,49 +76,42 @@ describe("Dashboard", () => {
this.dashboardEditUrl = `/dashboards/${id}?edit`; this.dashboardEditUrl = `/dashboards/${id}?edit`;
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId); return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
}) })
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).as("textboxEl"); cy.getByTestId(elTestId).as("textboxEl");
}); });
}); });
beforeEach(function() { beforeEach(function () {
cy.login(); cy.login();
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.viewport(800 + menuWidth, 800); cy.viewport(800 + menuWidth, 800);
}); });
it("shows widgets with full width", () => { it("shows widgets with full width", () => {
cy.get("@textboxEl").should($el => { cy.get("@textboxEl").should(($el) => {
expect($el.width()).to.eq(770); expect($el.width()).to.eq(770);
}); });
cy.viewport(801 + menuWidth, 800); cy.viewport(801 + menuWidth, 800);
cy.get("@textboxEl").should($el => { cy.get("@textboxEl").should(($el) => {
expect($el.width()).to.eq(378); expect($el.width()).to.eq(182);
}); });
}); });
it("hides edit option", () => { it("hides edit option", () => {
cy.getByTestId("DashboardMoreButton") cy.getByTestId("DashboardMoreButton").click().should("be.visible");
.click()
.should("be.visible");
cy.getByTestId("DashboardMoreButtonMenu") cy.getByTestId("DashboardMoreButtonMenu").contains("Edit").as("editButton").should("not.be.visible");
.contains("Edit")
.as("editButton")
.should("not.be.visible");
cy.viewport(801 + menuWidth, 800); cy.viewport(801 + menuWidth, 800);
cy.get("@editButton").should("be.visible"); cy.get("@editButton").should("be.visible");
}); });
it("disables edit mode", function() { it("disables edit mode", function () {
cy.viewport(801 + menuWidth, 800); cy.viewport(801 + menuWidth, 800);
cy.visit(this.dashboardEditUrl); cy.visit(this.dashboardEditUrl);
cy.contains("button", "Done Editing") cy.contains("button", "Done Editing").as("saveButton").should("exist");
.as("saveButton")
.should("exist");
cy.viewport(800 + menuWidth, 800); cy.viewport(800 + menuWidth, 800);
cy.contains("button", "Done Editing").should("not.exist"); cy.contains("button", "Done Editing").should("not.exist");
@@ -130,14 +119,14 @@ describe("Dashboard", () => {
}); });
context("viewport width is at 767px", () => { context("viewport width is at 767px", () => {
before(function() { before(function () {
cy.login(); cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => { cy.createDashboard("Foo Bar").then(({ id }) => {
this.dashboardUrl = `/dashboards/${id}`; this.dashboardUrl = `/dashboards/${id}`;
}); });
}); });
beforeEach(function() { beforeEach(function () {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.viewport(767, 800); cy.viewport(767, 800);
}); });

View File

@@ -23,7 +23,7 @@ describe("Dashboard Filters", () => {
name: "Query Filters", name: "Query Filters",
query: `SELECT stage1 AS "stage1::filter", stage2, value FROM (${SQL}) q`, query: `SELECT stage1 AS "stage1::filter", stage2, value FROM (${SQL}) q`,
}; };
cy.createDashboard("Dashboard Filters").then(dashboard => { cy.createDashboard("Dashboard Filters").then((dashboard) => {
createQueryAndAddWidget(dashboard.id, queryData) createQueryAndAddWidget(dashboard.id, queryData)
.as("widget1TestId") .as("widget1TestId")
.then(() => createQueryAndAddWidget(dashboard.id, queryData, { position: { col: 4 } })) .then(() => createQueryAndAddWidget(dashboard.id, queryData, { position: { col: 4 } }))
@@ -32,26 +32,23 @@ describe("Dashboard Filters", () => {
}); });
}); });
it("filters rows in a Table Visualization", function() { it("filters rows in a Table Visualization", function () {
editDashboard(); editDashboard();
cy.getByTestId("DashboardFilters").should("not.exist"); cy.getByTestId("DashboardFilters").should("not.exist");
cy.getByTestId("DashboardFiltersCheckbox").click(); cy.getByTestId("DashboardFiltersCheckbox").click();
cy.getByTestId("DashboardFilters").within(() => { cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter") cy.getByTestId("FilterName-stage1::filter").find(".ant-select-selection-item").should("have.text", "a");
.find(".ant-select-selection-item")
.should("have.text", "a");
}); });
cy.getByTestId(this.widget1TestId).within(() => { cy.getByTestId(this.widget1TestId).within(() => {
expectTableToHaveLength(4); expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["a", "a", "a", "a"]); expectFirstColumnToHaveMembers(["a", "a", "a", "a"]);
cy.getByTestId("FilterName-stage1::filter") cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click();
.find(".ant-select")
.click();
}); });
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains(".ant-select-item-option-content:visible", "b").click(); cy.contains(".ant-select-item-option-content:visible", "b").click();
cy.getByTestId(this.widget1TestId).within(() => { cy.getByTestId(this.widget1TestId).within(() => {
@@ -69,14 +66,13 @@ describe("Dashboard Filters", () => {
// assert that changing a global filter affects all widgets // assert that changing a global filter affects all widgets
cy.getByTestId("DashboardFilters").within(() => { cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter") cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click();
.find(".ant-select")
.click();
}); });
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains(".ant-select-item-option-content:visible", "c").click(); cy.contains(".ant-select-item-option-content:visible", "c").click();
[this.widget1TestId, this.widget2TestId].forEach(widgetTestId => [this.widget1TestId, this.widget2TestId].forEach((widgetTestId) =>
cy.getByTestId(widgetTestId).within(() => { cy.getByTestId(widgetTestId).within(() => {
expectTableToHaveLength(4); expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["c", "c", "c", "c"]); expectFirstColumnToHaveMembers(["c", "c", "c", "c"]);

View File

@@ -5,7 +5,7 @@ import { getWidgetTestId, editDashboard, resizeBy } from "../../support/dashboar
const menuWidth = 80; const menuWidth = 80;
describe("Grid compliant widgets", () => { describe("Grid compliant widgets", () => {
beforeEach(function() { beforeEach(function () {
cy.login(); cy.login();
cy.viewport(1215 + menuWidth, 800); cy.viewport(1215 + menuWidth, 800);
cy.createDashboard("Foo Bar") cy.createDashboard("Foo Bar")
@@ -13,7 +13,7 @@ describe("Grid compliant widgets", () => {
this.dashboardUrl = `/dashboards/${id}`; this.dashboardUrl = `/dashboards/${id}`;
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId); return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
}) })
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).as("textboxEl"); cy.getByTestId(elTestId).as("textboxEl");
}); });
@@ -27,7 +27,7 @@ describe("Grid compliant widgets", () => {
it("stays put when dragged under snap threshold", () => { it("stays put when dragged under snap threshold", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(90) .dragBy(30)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15 .should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15
}); });
@@ -36,14 +36,14 @@ describe("Grid compliant widgets", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(110) .dragBy(110)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215 .should("have.property", "left", 115 + menuWidth); // moved by 100, 15 -> 115
}); });
it("moves two columns when dragged over snap threshold", () => { it("moves two columns when dragged over snap threshold", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(330) .dragBy(200)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 415 + menuWidth); // moved by 400, 15 -> 415 .should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215
}); });
}); });
@@ -52,7 +52,7 @@ describe("Grid compliant widgets", () => {
cy.route("POST", "**/api/widgets/*").as("WidgetSave"); cy.route("POST", "**/api/widgets/*").as("WidgetSave");
editDashboard(); editDashboard();
cy.get("@textboxEl").dragBy(330); cy.get("@textboxEl").dragBy(100);
cy.wait("@WidgetSave"); cy.wait("@WidgetSave");
}); });
}); });
@@ -64,24 +64,24 @@ describe("Grid compliant widgets", () => {
}); });
it("stays put when dragged under snap threshold", () => { it("stays put when dragged under snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 90) resizeBy(cy.get("@textboxEl"), 30)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 585); // no change, 585 -> 585 .should("eq", 285); // no change, 285 -> 285
}); });
it("moves one column when dragged over snap threshold", () => { it("moves one column when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 110) resizeBy(cy.get("@textboxEl"), 110)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 785); // resized by 200, 585 -> 785 .should("eq", 385); // resized by 200, 185 -> 385
}); });
it("moves two columns when dragged over snap threshold", () => { it("moves two columns when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 400) resizeBy(cy.get("@textboxEl"), 400)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 985); // resized by 400, 585 -> 985 .should("eq", 685); // resized by 400, 285 -> 685
}); });
}); });
@@ -101,16 +101,16 @@ describe("Grid compliant widgets", () => {
resizeBy(cy.get("@textboxEl"), 0, 30) resizeBy(cy.get("@textboxEl"), 0, 30)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("height") .invoke("height")
.should("eq", 185); // resized by 50, , 135 -> 185 .should("eq", 185);
}); });
it("shrinks to minimum", () => { it("shrinks to minimum", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.then($el => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0 .then(($el) => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.should($el => { .should(($el) => {
expect($el.width()).to.eq(185); // min textbox width expect($el.width()).to.eq(185); // min textbox width
expect($el.height()).to.eq(35); // min textbox height expect($el.height()).to.eq(85); // min textbox height
}); });
}); });
}); });

View File

@@ -3,7 +3,7 @@
import { getWidgetTestId, editDashboard } from "../../support/dashboard"; import { getWidgetTestId, editDashboard } from "../../support/dashboard";
describe("Textbox", () => { describe("Textbox", () => {
beforeEach(function() { beforeEach(function () {
cy.login(); cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => { cy.createDashboard("Foo Bar").then(({ id }) => {
this.dashboardId = id; this.dashboardId = id;
@@ -12,12 +12,10 @@ describe("Textbox", () => {
}); });
const confirmDeletionInModal = () => { const confirmDeletionInModal = () => {
cy.get(".ant-modal .ant-btn") cy.get(".ant-modal .ant-btn").contains("Delete").click({ force: true });
.contains("Delete")
.click({ force: true });
}; };
it("adds textbox", function() { it("adds textbox", function () {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
editDashboard(); editDashboard();
cy.getByTestId("AddTextboxButton").click(); cy.getByTestId("AddTextboxButton").click();
@@ -29,10 +27,10 @@ describe("Textbox", () => {
cy.get(".widget-text").should("exist"); cy.get(".widget-text").should("exist");
}); });
it("removes textbox by X button", function() { it("removes textbox by X button", function () {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
editDashboard(); editDashboard();
@@ -45,32 +43,30 @@ describe("Textbox", () => {
}); });
}); });
it("removes textbox by menu", function() { it("removes textbox by menu", function () {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).within(() => { cy.getByTestId(elTestId).within(() => {
cy.getByTestId("WidgetDropdownButton").click(); cy.getByTestId("WidgetDropdownButton").click();
}); });
cy.getByTestId("WidgetDropdownButtonMenu") cy.getByTestId("WidgetDropdownButtonMenu").contains("Remove from Dashboard").click();
.contains("Remove from Dashboard")
.click();
confirmDeletionInModal(); confirmDeletionInModal();
cy.getByTestId(elTestId).should("not.exist"); cy.getByTestId(elTestId).should("not.exist");
}); });
}); });
it("allows opening menu after removal", function() { it("allows opening menu after removal", function () {
let elTestId1; let elTestId1;
cy.addTextbox(this.dashboardId, "txb 1") cy.addTextbox(this.dashboardId, "txb 1")
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
elTestId1 = elTestId; elTestId1 = elTestId;
return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId); return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId);
}) })
.then(elTestId2 => { .then((elTestId2) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
editDashboard(); editDashboard();
@@ -97,10 +93,10 @@ describe("Textbox", () => {
}); });
}); });
it("edits textbox", function() { it("edits textbox", function () {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId) cy.getByTestId(elTestId)
.as("textboxEl") .as("textboxEl")
@@ -108,17 +104,13 @@ describe("Textbox", () => {
cy.getByTestId("WidgetDropdownButton").click(); cy.getByTestId("WidgetDropdownButton").click();
}); });
cy.getByTestId("WidgetDropdownButtonMenu") cy.getByTestId("WidgetDropdownButtonMenu").contains("Edit").click();
.contains("Edit")
.click();
const newContent = "[edited]"; const newContent = "[edited]";
cy.getByTestId("TextboxDialog") cy.getByTestId("TextboxDialog")
.should("exist") .should("exist")
.within(() => { .within(() => {
cy.get("textarea") cy.get("textarea").clear().type(newContent);
.clear()
.type(newContent);
cy.contains("button", "Save").click(); cy.contains("button", "Save").click();
}); });
@@ -126,7 +118,7 @@ describe("Textbox", () => {
}); });
}); });
it("renders textbox according to position configuration", function() { it("renders textbox according to position configuration", function () {
const id = this.dashboardId; const id = this.dashboardId;
const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 }; const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 };
const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 }; const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 };
@@ -135,15 +127,15 @@ describe("Textbox", () => {
cy.addTextbox(id, "x", { position: txb1Pos }) cy.addTextbox(id, "x", { position: txb1Pos })
.then(() => cy.addTextbox(id, "x", { position: txb2Pos })) .then(() => cy.addTextbox(id, "x", { position: txb2Pos }))
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
return cy.getByTestId(elTestId); return cy.getByTestId(elTestId);
}) })
.should($el => { .should(($el) => {
const { top, left } = $el.offset(); const { top, left } = $el.offset();
expect(top).to.be.oneOf([162, 162.015625]); expect(top).to.be.oneOf([162, 162.015625]);
expect(left).to.eq(282); expect(left).to.eq(188);
expect($el.width()).to.eq(545); expect($el.width()).to.eq(265);
expect($el.height()).to.eq(185); expect($el.height()).to.eq(185);
}); });
}); });

View File

@@ -5,8 +5,9 @@ describe("Embedded Queries", () => {
}); });
it("is unavailable when public urls feature is disabled", () => { it("is unavailable when public urls feature is disabled", () => {
cy.createQuery({ query: "select name from users order by name" }).then(query => { cy.createQuery({ query: "select name from users order by name" }).then((query) => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.clickThrough(` cy.clickThrough(`
@@ -15,7 +16,7 @@ describe("Embedded Queries", () => {
`); `);
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then(embedUrl => { .then((embedUrl) => {
// disable the feature // disable the feature
cy.updateOrgSettings({ disable_public_urls: true }); cy.updateOrgSettings({ disable_public_urls: true });
@@ -23,9 +24,7 @@ describe("Embedded Queries", () => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.getByTestId("QueryPageHeaderMoreButton").click(); cy.getByTestId("QueryPageHeaderMoreButton").click();
cy.get(".ant-dropdown-menu-item") cy.get(".ant-dropdown-menu-item").should("exist").should("not.contain", "Show API Key");
.should("exist")
.should("not.contain", "Show API Key");
cy.getByTestId("QueryControlDropdownButton").click(); cy.getByTestId("QueryControlDropdownButton").click();
cy.get(".ant-dropdown-menu-item").should("exist"); cy.get(".ant-dropdown-menu-item").should("exist");
cy.getByTestId("ShowEmbedDialogButton").should("not.exist"); cy.getByTestId("ShowEmbedDialogButton").should("not.exist");
@@ -42,8 +41,9 @@ describe("Embedded Queries", () => {
}); });
it("can be shared without parameters", () => { it("can be shared without parameters", () => {
cy.createQuery({ query: "select name from users order by name" }).then(query => { cy.createQuery({ query: "select name from users order by name" }).then((query) => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.clickThrough(` cy.clickThrough(`
@@ -52,7 +52,7 @@ describe("Embedded Queries", () => {
`); `);
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then(embedUrl => { .then((embedUrl) => {
cy.logout(); cy.logout();
cy.visit(embedUrl); cy.visit(embedUrl);
cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist"); cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist");
@@ -90,7 +90,7 @@ describe("Embedded Queries", () => {
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then(embedUrl => { .then((embedUrl) => {
cy.logout(); cy.logout();
cy.visit(embedUrl); cy.visit(embedUrl);
cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist"); cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist");

View File

@@ -2,16 +2,14 @@ import { dragParam } from "../../support/parameters";
import dayjs from "dayjs"; import dayjs from "dayjs";
function openAndSearchAntdDropdown(testId, paramOption) { function openAndSearchAntdDropdown(testId, paramOption) {
cy.getByTestId(testId) cy.getByTestId(testId).find(".ant-select-selection-search-input").type(paramOption, { force: true });
.find(".ant-select-selection-search-input")
.type(paramOption, { force: true });
} }
describe("Parameter", () => { describe("Parameter", () => {
const expectDirtyStateChange = edit => { const expectDirtyStateChange = (edit) => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter")
.find(".parameter-input") .find(".parameter-input")
.should($el => { .should(($el) => {
assert.isUndefined($el.data("dirty")); assert.isUndefined($el.data("dirty"));
}); });
@@ -19,7 +17,7 @@ describe("Parameter", () => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter")
.find(".parameter-input") .find(".parameter-input")
.should($el => { .should(($el) => {
assert.isTrue($el.data("dirty")); assert.isTrue($el.data("dirty"));
}); });
}; };
@@ -42,9 +40,7 @@ describe("Parameter", () => {
}); });
it("updates the results after clicking Apply", () => { it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
.find("input")
.type("Redash");
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
@@ -53,13 +49,66 @@ describe("Parameter", () => {
it("sets dirty state when edited", () => { it("sets dirty state when edited", () => {
expectDirtyStateChange(() => { expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
.find("input")
.type("Redash");
}); });
}); });
}); });
describe("Text Pattern Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Text Pattern Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text-pattern", regex: "a.*a" }],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
});
it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "arta");
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arounda");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "arounda");
});
it("throws error message with invalid query request", () => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}abcab");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("QueryExecutionStatus").should("exist");
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
});
});
it("doesn't let user save invalid regex", () => {
cy.get(".fa-cog").click();
cy.getByTestId("RegexPatternInput").type("{selectall}[");
cy.contains("Invalid Regex Pattern").should("exist");
cy.getByTestId("SaveParameterSettings").click();
cy.get(".fa-cog").click();
cy.getByTestId("RegexPatternInput").should("not.equal", "[");
});
});
describe("Number Parameter", () => { describe("Number Parameter", () => {
beforeEach(() => { beforeEach(() => {
const queryData = { const queryData = {
@@ -74,17 +123,13 @@ describe("Parameter", () => {
}); });
it("updates the results after clicking Apply", () => { it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
.find("input")
.type("{selectall}42");
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", 42); cy.getByTestId("TableVisualization").should("contain", 42);
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}31415");
.find("input")
.type("{selectall}31415");
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
@@ -93,9 +138,7 @@ describe("Parameter", () => {
it("sets dirty state when edited", () => { it("sets dirty state when edited", () => {
expectDirtyStateChange(() => { expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
.find("input")
.type("{selectall}42");
}); });
}); });
}); });
@@ -119,10 +162,7 @@ describe("Parameter", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
// only the filtered option should be on the DOM // only the filtered option should be on the DOM
cy.get(".ant-select-item-option") cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
.should("have.length", 1)
.and("contain", "value2")
.click();
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed // ensure that query is being executed
@@ -140,12 +180,10 @@ describe("Parameter", () => {
SaveParameterSettings SaveParameterSettings
`); `);
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find(".ant-select-selection-search").click();
.find(".ant-select-selection-search")
.click();
// select all unselected options // select all unselected options
cy.get(".ant-select-item-option").each($option => { cy.get(".ant-select-item-option").each(($option) => {
if (!$option.hasClass("ant-select-item-option-selected")) { if (!$option.hasClass("ant-select-item-option-selected")) {
cy.wrap($option).click(); cy.wrap($option).click();
} }
@@ -160,9 +198,7 @@ describe("Parameter", () => {
it("sets dirty state when edited", () => { it("sets dirty state when edited", () => {
expectDirtyStateChange(() => { expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
.find(".ant-select")
.click();
cy.contains(".ant-select-item-option", "value2").click(); cy.contains(".ant-select-item-option", "value2").click();
}); });
@@ -176,7 +212,7 @@ describe("Parameter", () => {
name: "Dropdown Query", name: "Dropdown Query",
query: "", query: "",
}; };
cy.createQuery(dropdownQueryData, true).then(dropdownQuery => { cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
const queryData = { const queryData = {
name: "Query Based Dropdown Parameter", name: "Query Based Dropdown Parameter",
query: "SELECT '{{test-parameter}}' AS parameter", query: "SELECT '{{test-parameter}}' AS parameter",
@@ -208,7 +244,7 @@ describe("Parameter", () => {
SELECT 'value2' AS name, 2 AS value UNION ALL SELECT 'value2' AS name, 2 AS value UNION ALL
SELECT 'value3' AS name, 3 AS value`, SELECT 'value3' AS name, 3 AS value`,
}; };
cy.createQuery(dropdownQueryData, true).then(dropdownQuery => { cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
const queryData = { const queryData = {
name: "Query Based Dropdown Parameter", name: "Query Based Dropdown Parameter",
query: "SELECT '{{test-parameter}}' AS parameter", query: "SELECT '{{test-parameter}}' AS parameter",
@@ -234,10 +270,7 @@ describe("Parameter", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
// only the filtered option should be on the DOM // only the filtered option should be on the DOM
cy.get(".ant-select-item-option") cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
.should("have.length", 1)
.and("contain", "value2")
.click();
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed // ensure that query is being executed
@@ -255,12 +288,10 @@ describe("Parameter", () => {
SaveParameterSettings SaveParameterSettings
`); `);
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
.find(".ant-select")
.click();
// make sure all options are unselected and select all // make sure all options are unselected and select all
cy.get(".ant-select-item-option").each($option => { cy.get(".ant-select-item-option").each(($option) => {
expect($option).not.to.have.class("ant-select-dropdown-menu-item-selected"); expect($option).not.to.have.class("ant-select-dropdown-menu-item-selected");
cy.wrap($option).click(); cy.wrap($option).click();
}); });
@@ -274,14 +305,10 @@ describe("Parameter", () => {
}); });
}); });
const selectCalendarDate = date => { const selectCalendarDate = (date) => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").click();
.find("input")
.click();
cy.get(".ant-picker-panel") cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", date).click();
.contains(".ant-picker-cell-inner", date)
.click();
}; };
describe("Date Parameter", () => { describe("Date Parameter", () => {
@@ -303,10 +330,10 @@ describe("Parameter", () => {
}); });
afterEach(() => { afterEach(() => {
cy.clock().then(clock => clock.restore()); cy.clock().then((clock) => clock.restore());
}); });
it("updates the results after selecting a date", function() { it("updates the results after selecting a date", function () {
selectCalendarDate("15"); selectCalendarDate("15");
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
@@ -314,12 +341,10 @@ describe("Parameter", () => {
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("15/MM/YY")); cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("15/MM/YY"));
}); });
it("allows picking a dynamic date", function() { it("allows picking a dynamic date", function () {
cy.getByTestId("DynamicButton").click(); cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu") cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
.contains("Today/Now")
.click();
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
@@ -350,14 +375,11 @@ describe("Parameter", () => {
}); });
afterEach(() => { afterEach(() => {
cy.clock().then(clock => clock.restore()); cy.clock().then((clock) => clock.restore());
}); });
it("updates the results after selecting a date and clicking in ok", function() { it("updates the results after selecting a date and clicking in ok", function () {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
.find("input")
.as("Input")
.click();
selectCalendarDate("15"); selectCalendarDate("15");
@@ -368,27 +390,20 @@ describe("Parameter", () => {
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-15 HH:mm")); cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-15 HH:mm"));
}); });
it("shows the current datetime after clicking in Now", function() { it("shows the current datetime after clicking in Now", function () {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
.find("input")
.as("Input")
.click();
cy.get(".ant-picker-panel") cy.get(".ant-picker-panel").contains("Now").click();
.contains("Now")
.click();
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-DD HH:mm")); cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-DD HH:mm"));
}); });
it("allows picking a dynamic date", function() { it("allows picking a dynamic date", function () {
cy.getByTestId("DynamicButton").click(); cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu") cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
.contains("Today/Now")
.click();
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
@@ -397,31 +412,20 @@ describe("Parameter", () => {
it("sets dirty state when edited", () => { it("sets dirty state when edited", () => {
expectDirtyStateChange(() => { expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").click();
.find("input")
.click();
cy.get(".ant-picker-panel") cy.get(".ant-picker-panel").contains("Now").click();
.contains("Now")
.click();
}); });
}); });
}); });
describe("Date Range Parameter", () => { describe("Date Range Parameter", () => {
const selectCalendarDateRange = (startDate, endDate) => { const selectCalendarDateRange = (startDate, endDate) => {
cy.getByTestId("ParameterName-test-parameter") cy.getByTestId("ParameterName-test-parameter").find("input").first().click();
.find("input")
.first()
.click();
cy.get(".ant-picker-panel") cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", startDate).click();
.contains(".ant-picker-cell-inner", startDate)
.click();
cy.get(".ant-picker-panel") cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", endDate).click();
.contains(".ant-picker-cell-inner", endDate)
.click();
}; };
beforeEach(() => { beforeEach(() => {
@@ -442,10 +446,10 @@ describe("Parameter", () => {
}); });
afterEach(() => { afterEach(() => {
cy.clock().then(clock => clock.restore()); cy.clock().then((clock) => clock.restore());
}); });
it("updates the results after selecting a date range", function() { it("updates the results after selecting a date range", function () {
selectCalendarDateRange("15", "20"); selectCalendarDateRange("15", "20");
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
@@ -457,12 +461,10 @@ describe("Parameter", () => {
); );
}); });
it("allows picking a dynamic date range", function() { it("allows picking a dynamic date range", function () {
cy.getByTestId("DynamicButton").click(); cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu") cy.getByTestId("DynamicButtonMenu").contains("Last month").click();
.contains("Last month")
.click();
cy.getByTestId("ParameterApplyButton").click(); cy.getByTestId("ParameterApplyButton").click();
@@ -479,15 +481,10 @@ describe("Parameter", () => {
}); });
describe("Apply Changes", () => { describe("Apply Changes", () => {
const expectAppliedChanges = apply => { const expectAppliedChanges = (apply) => {
cy.getByTestId("ParameterName-test-parameter-1") cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
.find("input")
.as("Input")
.type("Redash");
cy.getByTestId("ParameterName-test-parameter-2") cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
.find("input")
.type("Redash");
cy.location("search").should("not.contain", "Redash"); cy.location("search").should("not.contain", "Redash");
@@ -523,10 +520,7 @@ describe("Parameter", () => {
it("shows and hides according to parameter dirty state", () => { it("shows and hides according to parameter dirty state", () => {
cy.getByTestId("ParameterApplyButton").should("not.be", "visible"); cy.getByTestId("ParameterApplyButton").should("not.be", "visible");
cy.getByTestId("ParameterName-test-parameter-1") cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Param").type("Redash");
.find("input")
.as("Param")
.type("Redash");
cy.getByTestId("ParameterApplyButton").should("be.visible"); cy.getByTestId("ParameterApplyButton").should("be.visible");
@@ -536,21 +530,13 @@ describe("Parameter", () => {
}); });
it("updates dirty counter", () => { it("updates dirty counter", () => {
cy.getByTestId("ParameterName-test-parameter-1") cy.getByTestId("ParameterName-test-parameter-1").find("input").type("Redash");
.find("input")
.type("Redash");
cy.getByTestId("ParameterApplyButton") cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "1");
.find(".ant-badge-count p.current")
.should("contain", "1");
cy.getByTestId("ParameterName-test-parameter-2") cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
.find("input")
.type("Redash");
cy.getByTestId("ParameterApplyButton") cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "2");
.find(".ant-badge-count p.current")
.should("contain", "2");
}); });
it('applies changes from "Apply Changes" button', () => { it('applies changes from "Apply Changes" button', () => {
@@ -560,16 +546,13 @@ describe("Parameter", () => {
}); });
it('applies changes from "alt+enter" keyboard shortcut', () => { it('applies changes from "alt+enter" keyboard shortcut', () => {
expectAppliedChanges(input => { expectAppliedChanges((input) => {
input.type("{alt}{enter}"); input.type("{alt}{enter}");
}); });
}); });
it('disables "Execute" button', () => { it('disables "Execute" button', () => {
cy.getByTestId("ParameterName-test-parameter-1") cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
.find("input")
.as("Input")
.type("Redash");
cy.getByTestId("ExecuteButton").should("be.disabled"); cy.getByTestId("ExecuteButton").should("be.disabled");
cy.get("@Input").clear(); cy.get("@Input").clear();
@@ -594,15 +577,12 @@ describe("Parameter", () => {
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`)); cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
cy.get(".parameter-block") cy.get(".parameter-block").first().invoke("width").as("paramWidth");
.first()
.invoke("width")
.as("paramWidth");
cy.get("body").type("{alt}D"); // hide schema browser cy.get("body").type("{alt}D"); // hide schema browser
}); });
it("is possible to rearrange parameters", function() { it("is possible to rearrange parameters", function () {
cy.server(); cy.server();
cy.route("POST", "**/api/queries/*").as("QuerySave"); cy.route("POST", "**/api/queries/*").as("QuerySave");

View File

@@ -44,6 +44,7 @@ describe("Box Plot", () => {
.then(({ id }) => cy.createVisualization(id, "BOXPLOT", "Boxplot (Deprecated)", {})) .then(({ id }) => cy.createVisualization(id, "BOXPLOT", "Boxplot (Deprecated)", {}))
.then(({ id: visualizationId, query_id: queryId }) => { .then(({ id: visualizationId, query_id: queryId }) => {
cy.visit(`queries/${queryId}/source#${visualizationId}`); cy.visit(`queries/${queryId}/source#${visualizationId}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
@@ -61,9 +62,7 @@ describe("Box Plot", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("svg").should("exist");
.find("svg")
.should("exist");
cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] });
}); });

View File

@@ -26,33 +26,34 @@ const SQL = `
describe("Chart", () => { describe("Chart", () => {
beforeEach(() => { beforeEach(() => {
cy.login(); cy.login();
cy.createQuery({ name: "Chart Visualization", query: SQL }) cy.createQuery({ name: "Chart Visualization", query: SQL }).its("id").as("queryId");
.its("id")
.as("queryId");
}); });
it("creates Bar charts", function() { it("creates Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => { const getBarChartAssertionFunction =
// checks for TabbedEditor standard tabs (specificBarChartAssertionFn = () => {}) =>
assertTabbedEditor(); () => {
// checks for TabbedEditor standard tabs
assertTabbedEditor();
// standard chart should be bar // standard chart should be bar
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar"); cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
// checks the plot canvas exists and is empty // checks the plot canvas exists and is empty
assertPlotPreview("not.exist"); assertPlotPreview("not.exist");
// creates a chart and checks it is plotted // creates a chart and checks it is plotted
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage"); cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1"); cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2"); cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
assertPlotPreview("exist"); assertPlotPreview("exist");
specificBarChartAssertionFn(); specificBarChartAssertionFn();
}; };
const chartTests = [ const chartTests = [
{ {
@@ -95,8 +96,8 @@ describe("Chart", () => {
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => { const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
cy.visit(dashboardUrl); cy.visit(dashboardUrl);
widgetGetters.forEach(widgetGetter => { widgetGetters.forEach((widgetGetter) => {
cy.get(`@${widgetGetter}`).then(widget => { cy.get(`@${widgetGetter}`).then((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() => { cy.getByTestId(getWidgetTestId(widget)).within(() => {
cy.get("g.points").should("exist"); cy.get("g.points").should("exist");
}); });
@@ -107,4 +108,36 @@ describe("Chart", () => {
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn); createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
cy.percySnapshot("Visualizations - Charts - Bar"); cy.percySnapshot("Visualizations - Charts - Bar");
}); });
it("colors Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionViridis").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionTableau 10").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionD3 Category 10").click();
});
it("colors Pie charts", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.GlobalSeriesType").click();
cy.getByTestId("Chart.ChartType.pie").click();
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionViridis").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionTableau 10").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionD3 Category 10").click();
});
}); });

View File

@@ -34,6 +34,7 @@ describe("Choropleth", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -76,9 +77,7 @@ describe("Choropleth", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".map-visualization-container.leaflet-container").should("exist");
.find(".map-visualization-container.leaflet-container")
.should("exist");
cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] });
}); });

View File

@@ -24,6 +24,7 @@ describe("Cohort", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -51,9 +52,7 @@ describe("Cohort", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] });
cy.clickThrough(` cy.clickThrough(`
@@ -64,9 +63,7 @@ describe("Cohort", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] });
}); });
}); });

View File

@@ -12,6 +12,7 @@ describe("Counter", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -24,9 +25,7 @@ describe("Counter", () => {
Counter.General.ValueColumn.a Counter.General.ValueColumn.a
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -43,9 +42,7 @@ describe("Counter", () => {
"Counter.General.Label": "Custom Label", "Counter.General.Label": "Custom Label",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -65,9 +62,7 @@ describe("Counter", () => {
"Counter.General.TargetValueRowNumber": "2", "Counter.General.TargetValueRowNumber": "2",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -83,9 +78,7 @@ describe("Counter", () => {
Counter.General.TargetValueColumn.b Counter.General.TargetValueColumn.b
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -106,9 +99,7 @@ describe("Counter", () => {
"Counter.General.TargetValueRowNumber": "2", "Counter.General.TargetValueRowNumber": "2",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -123,9 +114,7 @@ describe("Counter", () => {
Counter.General.CountRows Counter.General.CountRows
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -151,9 +140,7 @@ describe("Counter", () => {
"Counter.Formatting.StringSuffix": "%", "Counter.Formatting.StringSuffix": "%",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -180,9 +167,7 @@ describe("Counter", () => {
"Counter.Formatting.StringSuffix": "%", "Counter.Formatting.StringSuffix": "%",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting

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