Compare commits

...

289 Commits

Author SHA1 Message Date
Gabriel Dutra
19343a0520 Clear QueryBasedParameterInput 2020-11-23 19:18:35 -03:00
Gabriel Dutra
c1ed8848f0 Merge branch 'master' into query-based-dropdown--parameters 2020-11-23 16:42:06 -03:00
Gabriel Dutra
b40070d7f5 Use LabeledValues for parameterized queries 2020-11-23 16:40:18 -03:00
Rafael Wendel
fa2b57a209 Remove unwanted props from Select component (#5277)
* Explicitly selected props so as to avoid errors from non-wanted props

* Simplified approach

* Ran prettier 😬

* Fixed minor issues
2020-11-22 13:07:56 -03:00
Jiajie Zhong
132fed64b3 Correct cleanup_query_results comment (#5276)
Correct comment from QUERY_RESULTS_MAX_AGE
to QUERY_RESULTS_CLEANUP_MAX_AGE
2020-11-20 23:11:13 +02:00
Gabriel Dutra
bd9ce68f68 Don't filter out values when param has search 2020-11-20 15:14:17 -03:00
Gabriel Dutra
0c0b62ae1a Remove searchTerm from structure 2020-11-20 15:13:47 -03:00
Gabriel Dutra
08bcdf77d0 Mock query instead of query_has_parameters 2020-11-12 09:11:54 -03:00
Gabriel Dutra
aa2064b1ab Fix other dropdown_values usages to use query obj 2020-11-11 13:58:01 -03:00
Gabriel Dutra
d0a787cab1 Make NoResultFound invalid parameters 2020-11-10 22:10:47 -03:00
Gabriel Dutra
a741341938 Oops 2020-11-10 20:46:51 -03:00
Gabriel Dutra
53385fa24b Merge branch 'master' into query-based-dropdown--parameters 2020-11-10 15:19:43 -03:00
Gabriel Dutra
fa7ecca485 Frontend updates from internal fork (#5259)
* DynamicComponent for QuerySourceAlerts

* General Settings updates

* Dynamic Date[Range] updates

* EmptyState updates

* Query and SchemaBrowser updates

* Adjust page headers and add disablePublish

* Policy updates

* Separate Home FavoritesList component

* Update FormatQuery

* Autolimit frontend fixes

* Misc updates

* Keep registering of QuerySourceDropdown

* Undo changes in DynamicComponent

* Change sql-formatter package.json syntax

* Allow opening help trigger in new tab

* Don't run npm commands as root in Dockerfile

* Cypress: Remove extra execute query
2020-11-10 14:59:15 +02:00
deecay
8f484706b1 Enable Boxplot to be horizontal (#5262) 2020-11-08 23:17:08 +02:00
Josh Bohde
e2e8714155 Enable graceful shutdown of rq workers (#5214)
* Enable graceful shutdown of rq workers

* Use `exec` in the `worker` command of the entrypoint to propagate
  the `TERM` signal
* Allow rq processes managed by supervisor to exit without restart on
  expected status codes
* Allow supervisorctl to contact the running supervisor
* Add a `shutdown_worker` command that will send `TERM` to all running
  worker processes and then sleep. This allows orchestration systems
  to initiate a graceful shutdown before sending `SIGTERM` to
  supervisord

* Use Heroku worker as the BaseWorker

This implements a graceful shutdown on SIGTERM, which simplifies
external shutdown procedures.

* Fix imports based upon review

* Remove supervisorctl config
2020-11-05 11:49:45 +02:00
Jerry
c6bf8a1c55 bugfix: fix #5254 (#5255)
Co-authored-by: Jerry <jerry.yuan@webweye.com>
2020-11-04 20:56:41 +02:00
Rafael Wendel
12f71925c2 Multiselect dropdown slowness (fix) (#5221)
* created util to estimate reasonable width for dropdown

* removed unused import

* improved calculation of item percentile

* added getItemOfPercentileLength to relevant spots

* added getItemOfPercentileLength to relevant spots

* Added missing import

* created custom select element

* added check for property path

* removed uses of percentile util

* gave up on getting element reference

* finished testing Select component

* removed unused imports

* removed older uses of Option component

* added canvas calculation

* removed minWidth from Select

* improved calculation

* added fallbacks

* added estimated offset

* removed leftovers 😅

* replaced to percentiles to max value

* switched to memo and renamed component

* proper useMemo syntax

* Update client/app/components/Select.tsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* created custom restrictive types

* added quick const

* fixed style

* fixed generics

* added pos absolute to fix percy

* removed custom select from ParameterMappingInput

* applied prettier

* Revert "added pos absolute to fix percy"

This reverts commit 4daf1d4bef.

* Pin Percy version to 0.24.3

* Update client/app/components/ParameterMappingInput.jsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* renamed Select.jsx to SelectWithVirtualScroll

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-11-03 21:50:39 +02:00
Omer Lachish
cae088f35b extend the refresh_queries timeout from 3 minutes to 10 minutes (#5253) 2020-11-02 22:36:57 +02:00
Rafael Wendel
a3c79f26b9 Fix for the typo button in ParameterMappingInput (#5244) 2020-10-29 17:24:13 -03:00
Jonathan Hult
c7c92a3192 Fix annotation bug causing queries not to run - ORA-00933 (#5179) 2020-10-28 10:03:26 +02:00
Rafael Wendel
55cf17aa47 added required to Form.Item and Input for better UI (#5231)
* added required to Form.Item and Input for better UI

* removed required from input

* Revert "removed required from input"

This reverts commit b56cd76fa1.

* Redo "removed required from input"

* removed typo

Co-authored-by: rafawendel2010@gmail.com <rafawendel>
2020-10-28 09:37:16 +02:00
Levko Kravets
8dd76a00c5 Fix dashboard background grid (#5238) 2020-10-26 21:46:38 +02:00
Christopher Grant
e242ac2b10 Static SAML configuration and assertion encryption (#5175)
* Change front-end and data model for SAML2 auth - static configuration

* Add changes to use inline metadata.

* add switch for static and dynamic SAML configurations

* Fixed config of backend static/dynamic to match UI

* add ability to encrypt/decrypt SAML assertions with pem and crt files. Upgraded to pysaml2 6.1.0 to mitigate signature mismatch during decryption

* remove print debug statement

* Use utility to find xmlsec binary for encryption, formatting saml_auth module

* format SAML Javascript, revert want_signed_response to pre-PR value

* pysaml2's entityid should point to the sp, not the idp

* add logging for entityid for validation

* use mustache_render instead of string formatting. put all static logic into static branch

* move mustache template for inline saml metadata to the global level

* Incorporate SAML type with Enabled setting

* Update client/app/pages/settings/components/AuthSettings/SAMLSettings.jsx

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Chad Chen <chad.chen@databricks.com>
Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-10-25 12:06:45 -03:00
Gabriel Dutra
66463aedd4 Fix Home EmptyState help link (#5217) 2020-10-16 11:53:21 -03:00
Rafael Wendel
8a6524c1ba Add horizontal bar chart (#5154)
* added bar chart boilerplate

* added x/y manipulation

* replaced x/y management to inner series preparer

* added tests

* moved axis inversion to all charts series

* removed line and area

* inverted labels ui

* removed normalizer check, simplified inverted axes check

* finished working hbar

* minor review

* added conditional title to YAxis

* generalized horizontal chart for line charts, resetted state on globalSeriesType change

* fixed updates

* fixed updates to layout

* fixed minor issues

* removed right Y axis when axes inverted

* ran prettier

* fixed updater function conflict and misuse of getOptions

* renamed inverted to swapped

* created mappingtypes for swapped columns

* removed unused import

* minor polishing

* improved series behaviour in h-bar

* minor fix

* added basic filter to ChartTypeSelect

* final setup of filtered chart types

* Update viz-lib/src/components/visualizations/editor/createTabbedEditor.jsx

* added proptypes and renamed ChartTypeSelect props

* Add missing import

* fixed import, moved result array to global scope

* merged import

* clearer naming in ChartTypeSelect

* better lodash map syntax

* fixed global modification

* moved result inside useMemo

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
Co-authored-by: Levko Kravets <levko.ne@gmail.com>
2020-10-15 21:34:38 +03:00
Gabriel Dutra
9097feb100 Frontend updates from internal fork (#5209) 2020-10-15 14:25:22 -03:00
Gabriel Dutra
db4e97fa6f Remove build args from Cypress start script (#5203) 2020-10-09 12:23:14 -03:00
Levko Kravets
0d4615a482 Extra actions on Queries and Dashboards pages (#5201)
* Extra actions for Query View and Query Source pages

* Convert Queries List page to functional component

* Convert Dashboards List page to functional component

* Extra actions for Query List page

* Extra actions for Dashboard List page

* Extra actions for Dashboard page

* Pass some extra data to Dashboard.HeaderExtra component

* CR1
2020-10-09 12:12:56 +03:00
Alexander Rusanov
ff008a076b Updated Cypress to v5.3 and fixed e2e tests (#5199)
* Upgraded Cypress to v5.3 and fixed e2e tests

* Updated cypress image

* Fixed failing tests

* Updated NODE_VERSION in netlify

* Update client/cypress/integration/visualizations/choropleth_spec.js

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* fixed test in choropleth

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-10-06 16:06:47 -03:00
Gabriel Dutra
8d548ecbac Share Embed Spec: Make sure query is executed (#5191) 2020-10-04 16:01:30 +03:00
Gabriel Dutra
2992c382d1 ScheduleDialog: Filter empty interval groups (#5196) 2020-10-03 05:54:05 +03:00
Gabriel Dutra
f4dcb2918a Move Cypress to dev dependencies (#3991)
* Test Cypress on package list

* Skip Puppeteer Chromium as well

* Put back missing npm install on netlify.toml

* Netlify: move env vars to build.environment

* Remove cypress:install script

* Update Cypress dockerfile

* Copy package-lock.json to Cypress dockerfile
2020-09-29 09:51:28 +03:00
Gabriel Dutra
c821cab4cb Generate Code Coverage report for Cypress (#5137) 2020-09-28 21:43:04 -03:00
Levko Kravets
4fb77867b0 Align Y axes at zero (#5053)
* Align Y axes as zero

* Fix typo (with @deecay)

* Add alignYAxesAtZero function

* Avoid 0 division

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-09-28 13:12:40 +03:00
Levko Kravets
a473611cb0 Some Choropleth improvements/refactoring (#5186)
* Directly map query results column to GeoJSON property

* Use cache for geoJson requests

* Don't handle bounds changes while loading geoJson data

* Choropleth: fix map "jumping" on load; don't save bounds if user didn't edit them; refine code a bit

* Improve cache

* Optimize Japan Perfectures map (remove irrelevant GeoJson properties)

* Improve getOptions for Choropleth; remove unused code

* Fix test

* Add US states map

* Convert USA map to Albers projection

* Allow to specify user-friendly field names for maps
2020-09-24 14:39:09 +03:00
Levko Kravets
210008c714 Ask user to log in when session expires (#5178)
* Ask user to log in when session expires

* Update implementation

* Update implementation

* Minor fix

* Update modal

* Do not intercept calls to api/session as Auth.requireSession() relies on it

* Refine code; adjust popup size and position
2020-09-23 16:30:08 +03:00
Omer Lachish
aa5d4f5f4e add 'cancelled' meta directive to all cancelled jobs (#5187) 2020-09-23 12:54:48 +03:00
Omer Lachish
6b811c5245 Refresh CSRF tokens (#5177)
* expire CSRF tokens after 6 hours

* use axios' built-in cookie to header copy mechanism

* add axios-auth-refresh

* retry CSRF-related 400 errors by refreshing the cookie

* export the auth refresh interceptor to support ejecting it if neccessary

* reject the original request if it's unrelated to CSRF
2020-09-21 23:21:14 +03:00
Levko Kravets
83726da48a Keep additional URL params when forking a query (#5184) 2020-09-21 12:54:55 +03:00
Levko Kravets
72dc157bbe Allow to clear selected tags on list pages (#5142)
* Convert TagsList to functional component

* Convert TagsList to typescript

* Allow to unselect all tags

* Add title to Tags block and explicit "clear filter" button

* Some tweaks
2020-09-17 14:01:15 +03:00
Lingkai Kong
1b8ff8e810 Add default limit (1000) to SQL queries (#5088)
* add default limit 1000

* Add frontend changes and connect to backend

* Fix query hash because of default limit

* fix CircleCI test

* adjust for comment
2020-09-14 14:18:31 +03:00
Omer Lachish
31ddd0fb79 prevent assigning queries to view_only data sources (#5152) 2020-09-10 15:43:25 +03:00
Levko Kravets
5cabf7a724 Keep selected filters when switching visualizations (#5146)
* getredash/redash#4944 Query pages: keep selected filters when switching visualizations

* Pass current filters to expanded widget modal
2020-09-10 13:42:53 +03:00
max-voronov
59b135ace7 Move CardsList to typescript (#5136)
* Refactor CardsList - pass a suffix for list item

Adding :id to an item to be used as a key suffix is redundant and the same
can be accomplished by using :index from the map function.

* Move CardsList to typescript

* Convert CardsList component to functional component

* CR1

* CR2
2020-09-05 20:08:01 +03:00
Gabriel Dutra
32b41e4112 Misc frontend changes from internal fork (#5143) 2020-09-04 08:00:30 -03:00
Gabriel Dutra
2e31b91054 Antd v4: Fix CreateUserDialog (#5139)
* Antd v4: Update CreateUserDialog

* Add Cypress test for user creation
2020-09-04 07:57:43 -03:00
Gabriel Dutra
205915e6db Add toggle to disable public URLs (#5140)
* Add toggle to disable public URLs

* Add Cypress tests
2020-09-01 08:49:30 -03:00
Levko Kravets
b7c245f925 Support multiple queries in a single query box (#5058)
* Support multiple queries in a single query box

* Implement statement splitting function and add tests for it

* Add a test for databricks-specific syntax

* Split statements before running query
2020-08-30 15:54:16 +03:00
Levko Kravets
681b2f1abd Introduce Link component (#5122)
* Introduce Link component

* Use Link component for external links as well

* Remove unused file (I hope it's really not needed)

* Use Link component in visualizations library

* Simplify Link component implementation

* CR1

* Trigger build

* CR2
2020-08-30 15:33:38 +03:00
Gabriel Dutra
a31196aef8 Upgrade Ant Design to v4 (#5068) 2020-08-25 14:24:15 -03:00
Arik Fraimovich
596e5bee3a Misc changes to codebase back ported from internal fork - part 2 (#5130)
* Auth: make login url configurable.

* More portable image url.

* durationHumanize: support for milliseconds.

* Sorter: support for custom sort.
2020-08-25 15:08:07 +03:00
Arik Fraimovich
84d516bfd1 Misc changes to codebase back ported from internal fork (#5129)
* Set corejs version in .babelrc so Jest doesn't complain.

* Rewrite services/routes in TypeScript.

* Add TypeScript definitions for DialogComponent.

* Make image paths more portable

* Add current route context and hook.

* Make EmptyState more flexible by being able to pass in getSteps function.

* Rewrite ItemsList in TypeScript.

* Introduce the possibility to add custom sorters for a column.

* Rearrange props to be friendly to TypeScript.

* Type definitions for NotificationApi.

* Use Databricks query editor components for databricks_internal type of query runner.

* URL Escape password in Alembic configuration.

* Compare types in migrations.
2020-08-25 14:11:38 +03:00
Gabriel Dutra
2cc3bd3d54 Add DynamicComponent to PermissionsControl flag (#5116) 2020-08-21 17:14:19 -03:00
Gabriel Dutra
ac652c20bf Fix create link on data sources page (#5121)
* Fix create link on data sources page

* Cypress: Add test that the source dialog opens
2020-08-21 16:47:42 -03:00
Gabriel Dutra
1bc6cd8f41 Keep widget loading when fetch request is replaced (#5118) 2020-08-20 19:00:57 -03:00
Levko Kravets
4c70b5ce8e Remove content width limit on all pages (#5091)
* Remove content width limit on all pages

* Update client/app/assets/less/inc/base.less

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* Remove content limit; limit sidebar width

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-08-20 15:04:53 +03:00
Omer Lachish
de052ff02b Cypress touch-ups (#5109)
* allow non-sequential IDs for DataSources in Cypress tests

* refactor redash-api to a set of Cypress commands

* support mounting Redash endpoints in Cypress routes

* fix some parameter specs by waiting for schema to load

* extract baseUrl from cypress.json

* Restyled by prettier (#5110)

Co-authored-by: Restyled.io <commits@restyled.io>

Co-authored-by: restyled-io[bot] <32688539+restyled-io[bot]@users.noreply.github.com>
Co-authored-by: Restyled.io <commits@restyled.io>
2020-08-19 21:00:06 +03:00
Gabriel Dutra
a596d6558c Use Skeleton as ItemsList loading state (#5079) 2020-08-19 09:36:11 -03:00
Gabriel Dutra
fc71acdc09 Make DataSourceListComponent a dynamic component (#5113) 2020-08-19 08:59:53 -03:00
Gabriel Dutra
b326d36ae8 Update Organization Settings (#5114)
* Update Organization Settings

* Cypress: Update tab naming
2020-08-19 13:09:28 +03:00
Omer Lachish
378cc57d42 CSRF Exempts (#5108)
* if present, always convert CSRF cookies to headers

* exempt auth blueprints from CSRF protection

* respect CSRF exempts
2020-08-17 22:39:46 +03:00
peterlee
83c6a6bcd2 Make table visualization header fixed (#5103)
* add lock table header

* Move styling to a new class

* Update renderer.less

* Move class to table and fix top border

* Update renderer.less

* Update viz-lib/src/visualizations/table/renderer.less

Thanks, this change is good to me.

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-08-17 09:26:34 -03:00
Omer Lachish
5afd0554d0 Add support for CSRF tokens (#5055)
* add flask-wtf

* add CSRF tokens to all static forms

* add CSRF tokens to all axios requests

* disable CSRF validation in unit tests

* support CSRF-protected requests in *most* cypress tests

* don't enfroce CSRF checks by default

* avoid CSRF enforcement in unit tests

* remove redundant spread

* some camel casing hiccups

* always yield the CSRF cookie, but avoid enforcing it if CSRF toggle is off

* Restyled by prettier (#5056)

Co-authored-by: Restyled.io <commits@restyled.io>

* set a CSRF header only if cookie is present

* enforce CSRF in CI

* install lodash directly for Cypress

* install request-cookies directly for Cypress. We should probably start loading package.json deps

* enable CSRF support when logout and login happen within the same spec

Co-authored-by: restyled-io[bot] <32688539+restyled-io[bot]@users.noreply.github.com>
Co-authored-by: Restyled.io <commits@restyled.io>
2020-08-09 15:47:00 +03:00
Levko Kravets
eb603f63f0 Bar chart with second y axis overlaps data series (#4150) 2020-08-05 20:28:03 +03:00
Arik Fraimovich
6c00f7c4e3 Add: periodic job to remove ghost locks. (#5087) 2020-08-05 17:48:19 +03:00
koooge
f56f4c4899 fix: Compose version due to --build-arg (#5083)
Signed-off-by: koooge <koooooge@gmail.com>
2020-08-05 12:41:25 +03:00
Tobias Macey
d3b639a68a Exposing setting for overriding template directory (#4324)
When using some of the customized login flows such as `REMOTE_USER` the deployed site breaks due to not finding template files. This change updated the app default to use the existing Flask templates directory rather than the compiled static assets directory which only contains an index.html file.
2020-08-04 12:05:43 +03:00
Gabriel Dutra
3332b656ac Make sure Policy is loaded for user session (#5081) 2020-07-31 01:39:30 -03:00
Gabriel Dutra
24c95379ca Introduce caching to the Databricks Schema Browser (#5038)
* Add refresh button in the bottom

* Add caching

* Drop allSettled

* Simplify refresh button

* Update error to return 500

* Load tables before loading columns

* Don't mutate schema

* Reset db name and schemas when changing data source

* Load both tables and columns

* Return error with code 200

* Code review updates

* Add expiration time to the cache Keys

* Back with RQ
2020-07-30 15:16:14 +03:00
Levko Kravets
93b4be672f Queries list: move "My Queries" above "Archived" (#5072) 2020-07-28 19:53:22 +03:00
Gabriel Dutra
f3a47a9658 Move page size select to the Paginator component (#5064) 2020-07-27 16:52:09 -03:00
Omer Lachish
7804dfd68e loosen up some proptypes and backend casting to allow different primary key types (#5066) 2020-07-27 17:01:59 +03:00
Ben Amor
2dacd08bea Add override mechanism for webpack config (#5057) 2020-07-27 14:52:02 +03:00
Gabriel Dutra
fd76a2ecfb Add Column Type to Databricks schema browser (#5052)
* Add Column Type to Databricks schema browser

* Map schema columns to be an object

* Format pg with Black

* Add data_type for Postgres
2020-07-23 12:52:09 +03:00
Omer Lachish
7f98d7b694 Load extensions on db init (#5062)
* Only try to create tables and stamp DB if not tables exist already.

* load extensions when creating the database
2020-07-23 11:05:20 +03:00
Levko Kravets
a1255b4144 Fix wrong Y-axis range for stacked bar chart (#5029)
* getredash/redash#5026 Fix wrong Y-axis range for stacked bar chart

* Update tests

* Use Plotly's built-in algorinthm to compute Y-axis range

* Update tests

* Revert previous solution (yRange-related code)

* Revert other unrelated changes

* Revert other unrelated changes

* Move chart rendering to own file and ensure that rendering steps will occur in necessary order

* Reduce amount of plot updates by mergin separate updates into a sigle cumulative update

* Give better names for several functions
2020-07-17 11:28:15 +03:00
dependabot[bot]
6c349ea70a Bump lodash from 4.17.15 to 4.17.19 in /viz-lib (#5051)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-16 22:41:16 -03:00
Omer Lachish
95c28c47ad Eager load outdated queries (#5049)
* eager load outdated queries

* explicitly use .all() instead of list()
2020-07-16 23:29:47 +03:00
simonschneider-db
48924de700 Add TypeScript support (#5027)
* TASK Add typescript dependencies to package.json

* TASK Add typescript to build process and npm scripts and TASK Move example components to typescript and add an example definition file.

* TASK Move back to ts-loader instead of babel typescript preset

* FIX Remove unnecessary changes

* FIX Explicitly mention tsconfig file in webpack.config.js to avoid `error while parsing tsconfig.json, The 'files' list in config file 'tsconfig.json' is empty`
See (https://github.com/TypeStrong/ts-loader/issues/405#issuecomment-330108362)

* FIX Move tsconfig to client subdirectory to make it accessible in docker container (only webpack.config.js is copied over from root folder in Dockerfile)

* TASK Move from ts-loader to babel to reduce compatibility issues between ES6/7 and typescript compilation.

* TASK Add types for classnames, hoist-non-react-statics and lodash. Fix default export of DashboardList and run prettier on eslintrc

* Run npm install

* Trigger tests

* Run npm install 2

* Trigger tests
2020-07-16 23:05:44 +03:00
Jannis Leidel
41a691328a Fix bundle-extensions script to work on recent importlib-resources. (#5050)
Also adds a test case for running the script.
2020-07-16 23:05:22 +03:00
Omer Lachish
cb97364771 Dashboard URL does not show new name when dashboard name is updated (#1009)
* on dashboard api calls - take the id from the beginning of the slug, unless there is no number in it - in that case, take the entire slug as id

* add dashboard id when showing links to dashboards

* change path to include new name when renaming dashboards

* move slug generation to backend

* redirect to new name after changing (this time with a proper promise)

* oh right, we already have a slug function

* add spec that makes sure that renamed dashboards are redirected to the
url which contains their new name

* use id-slug in all Cypress specs

* move dashboards from /dashboard/:slug to /dashboards/:id-:name_as_slug

* Update dashboard url as its name changes

* Update separator to be "/"

* Update missing dashboard urls

* Update api not to depend on int id

* Use '-' instead of '/' as separator and update Dashboard.get calls

* slug -> name_as_slug

* Keep slug urls on cypress

* Update route path

* Use legacy attr for GET

* Use getter for urlForDashboard

* Update dashboard url when loaded by slug

* Update Dashboard routes to use id instead of slug

* Update Dashboard handler tests

* Update Cypress tests

* Fix create new dashboard spec

* Use axios { params }

* Drop Ternary operator

* Send updated slug directly in 'slug' attr

* Update multiple urls Dashboard test name

* Update route names

Co-authored-by: Levko Kravets <levko.ne@gmail.com>

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
Co-authored-by: Levko Kravets <levko.ne@gmail.com>
2020-07-16 23:03:59 +03:00
Gabriel Dutra
d12691dc2a Databricks Schema Browser: Allow eventlet worker instead of RQ (#5045)
* Add loading button in UI

* Handle databricks schema requests without RQ

* Don't use gevent worker

* Revert "Don't use gevent worker"

This reverts commit 9704c70a94.

* Use eventlet

* Use first column instead of 'namespace' one

* Revert "Add loading button in UI"

This reverts commit c0e4dfb966.

* Remove databricks tasks

* Update eventlet

* Add libevent

* Display logs on failure

* Revert "Add libevent"

This reverts commit a00d067cb7.

* Test updating gunicorn

* Don't set eventlet as the default for Redash

Co-authored-by: Arik Fraimovich <arik@arikfr.com>

* Remove fetchDataFromJob usage

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-07-15 17:35:59 +03:00
Levko Kravets
6f9e79c641 getredash/redash#5031 Counter is too large on Query View/Source pages (#5044) 2020-07-14 21:56:01 +03:00
Gabriel Dutra
461f98bbfc Update Ace Editor version (#5041) 2020-07-14 10:02:33 -03:00
Levko Kravets
81e7c72d48 Allow to change order of legend items (#5021)
* Allow to change order of legend items

* Update tests
2020-07-13 19:08:51 +03:00
Gabriel Dutra
328f0f3f0c Visualizations Library: Enhance docs (#4946) 2020-07-12 12:36:03 -03:00
Omer Lachish
ecb9adf903 purge_failed_jobs can take up to several minutes, so better have a proper timeout (#5033) 2020-07-12 10:18:38 +03:00
Gabriel Dutra
87e09f676e Dynamic Form: Make default extra fields state a prop (#5039) 2020-07-09 12:39:25 -03:00
Gabriel Dutra
6fc5c803e0 Fix schema browser items height (#5024) 2020-07-08 11:55:10 -03:00
Gabriel Dutra
6c57aa448e Query Source: Add Shift+Enter shortcut for query execution (#5037) 2020-07-08 10:36:16 -03:00
Lei Ni
878b297601 Fix: sorting queries by schedule was resulting in a wrong order (#4954)
* fix schedule sorting issue

* style change

* Update to meet code style.

* move the schedule sort to backend

* mod comment

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-07-07 21:29:12 +03:00
Levko Kravets
9c0450c84e Explicitly sort routes to reduce a chance of conflicts (#5020)
* Explicitly sort routes to reduce (avoid at all?) a chance of conflicts

* Sort routes by params count
2020-07-03 21:11:39 +03:00
Levko Kravets
74f206614f Refactor: extract commonly used pattern into hook (#5022) 2020-07-03 10:44:51 +03:00
Gabriel Dutra
2f26cf791c Fix Databricks Schema Browser scrollbar (#5023) 2020-07-02 17:20:22 -03:00
Levko Kravets
c6be5758ad Y-axis autoscale fails when min or max is set (#4904)
* getredash/redash#4784 Y-axis autoscale fails when min or max is set

* Update tests

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-07-02 20:33:20 +03:00
Alex Kovar
8341592b05 Add plus between tags to clarify how they are used #4628 (#5017)
Co-authored-by: Levko Kravets <kravets-levko@users.noreply.github.com>

Co-authored-by: Levko Kravets <kravets-levko@users.noreply.github.com>
2020-07-02 18:44:38 +03:00
Gabriel Dutra
a7edbf1e8d Handle React exception when a function is provided (#5016) 2020-07-01 22:53:42 -03:00
Gabriel Dutra
217f41b586 Allow GET from non-admins on data source resource (#4992) 2020-07-01 10:10:24 -03:00
Gabriel Dutra
a8bd07e293 Databricks custom Schema Browser (#5010) 2020-07-01 10:09:18 -03:00
Omer Lachish
332c16b130 add a couple of missed custom key types hooks (#5014) 2020-07-01 12:39:46 +03:00
Vladislav Denisov
7940d36616 Python query runner fix (#4966)
* fixed print method

* fixed `.items()` error

* added extra builtins

* added guarded_unpack_sequence
2020-07-01 10:53:27 +03:00
Daniel Lang
68b70ed63b Fixed broken custom JS visualization settings (#5013) 2020-07-01 01:22:07 -03:00
Gabriel Dutra
e0297835df Add "Last 12 months" option to dynamic date ranges (#5004) 2020-06-30 09:56:00 -03:00
Omer Lachish
004bc7a2ac Custom primary/foreign key types (#5008)
* allow overriding the type of key used for primary/foreign keys of the different models

* rename key_types to singular key_type

* add some documentation for `database_key_definitions`
2020-06-30 15:08:28 +03:00
Gabriel Dutra
efcf22079f Allow private addresses when enforcing is disabled (#4983) 2020-06-30 10:54:49 +03:00
Levko Kravets
a83cb18cc5 Textbox: confirm close if text was changed (#5009)
* Textbox: confirm close if text was changed

* Update texting (with @gabrieldutra)

* Update texting

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-06-29 19:29:56 +03:00
Levko Kravets
1ecdf7b853 Too large visualization cause filters block to collapse (#5007) 2020-06-29 15:20:08 +03:00
Omer Lachish
90024ebc92 Delete locks for cancelled queries (#5006)
* delete locks for cancelled queries

* test that query cancellations do not prevent reenqueues
2020-06-29 13:09:01 +03:00
Gabriel Dutra
a37b7babbf Remove pace-progress (#4990) 2020-06-28 15:27:48 -03:00
Mike Nason
8f4ac958b1 Fix org option in users create_root cli command (#5003)
Thanks @nason 👍
2020-06-25 22:02:46 +03:00
Omer Lachish
637d9837f4 Avoid purging operational queues (#4973)
* avoid purging operational queues

* schema queues actually run queries, so they should be purged
2020-06-25 15:35:50 +03:00
Levko Kravets
bdd3c3e735 Dynamically register frontend routes (#4998)
* Allow to override frontend routes

* Configure app before initializing ApplicationArea

* Refine code
2020-06-25 13:38:23 +03:00
Levko Kravets
6fc35510d3 Allow unregistering settings tabs (#5000) 2020-06-25 12:54:46 +03:00
Levko Kravets
6f842ef94a Refactor User Profile page and add extension points to it (#4996)
* Move components specific to UserProfile page to corresponding folder

* Split UserProfile page into components

* Rename components, refine code a bit

* Add some extension points

* Fix margin
2020-06-25 12:03:19 +03:00
Levko Kravets
a563900f0a Refactor Organization Settings and add extension points to it (#4995)
* Split OrganizationSettings page into components

* Update change handling: use objects instead of string keys, move some logic to more appropriate place

* Convert OrganizationSettings page to functional component and refine code a bit

* Add some extension points

* Improve onChange handler

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-06-24 12:36:45 +03:00
Gabriel Dutra
ee3930c64d Catch QueryResultError on widget load (#4991) 2020-06-23 19:48:14 -03:00
Levko Kravets
10bff8b3b1 Some permissions fixes (#4994)
* Don't show New ... buttons on list pages if user doesn't have corresponding permissions

* Hide Create menu item if no create actions available
2020-06-23 22:56:24 +03:00
Jim Sparkman
a8510d1ad5 Fix CLI command for "status" (#4989)
* Fix CLI command for "status"

CLI command "status" can fail due to incorrect connection information to RQ.

This change matches the behavior from line 65 and solves the connection error.

* Move connection up to CLI entrypoint
2020-06-23 14:40:36 +03:00
Levko Kravets
3a543a4ab2 ErrorMessage is not centered (#4981)
* ErrorMessage is not centered

* Adjust ErrorMessage size on large screens

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-06-18 15:35:51 +03:00
Levko Kravets
2b1ba1ee33 Add New Dashboard/Query/Alert buttons to corresponding list pages (#4976) 2020-06-18 15:23:48 +03:00
Levko Kravets
4a54ad9d06 Add/Edit Tags dialog does not set focus on tags input (#4979)
* Refactor: convert EditTagsDialog to functional component; properly cleanup on destruction

* Pass focus to select when EditTagsDialog opens
2020-06-18 15:05:07 +03:00
Levko Kravets
676f560830 Navbar: show correct settings link for non-admin users (#4978)
* Non-admin users have an access to some settings, so need to show correct menu item in navbar

* Refine settings test

* Add some tests
2020-06-18 14:49:08 +03:00
Levko Kravets
98a5154345 Fix headless mode (didn't work with vertical navbar) (#4977)
* Fix headless mode (didn't work with vertical navbar)

* Fix header margins on public dashboard page

* Fix test
2020-06-17 11:10:12 +03:00
Gabriel Dutra
4c324ddc80 Custom Query components per Data Source type (#4948) 2020-06-15 16:13:10 -03:00
Gabriel Dutra
05c2233782 Don't reuse getErrorMessage (#4968) 2020-06-15 07:45:02 -03:00
Levko Kravets
0ac24e38a1 Vertical navbar (#4859)
* Vertical navbar

* Update vertical menu look and add create menu.

* Make query editor work with vertical nav.

* Dark mode

* Fix create menu & make sidebar fixed.

* Update Alert pages layout

* Update System status pages

* Update Queries and Dashboards list pages

* Update Query Source and Query View pages

* Use dark theme for mobile navbar

* Update Dashboard page: fix Add widget/textbox panel positioning

* Dashboard page: fix layout issues when container changes its size (fixes known issues: navbar expand/collapse, scrollbar appears/hides)

* Fix dashboard page sticky header (there was a 15px space above it)

* Fix embeds

* Extract desktop navbar component; move mobile navbar and its styles to ApplicationLayout folder

* Remove old app header

* Fix tests

* Restore version info block

* Make Percy capture entire page

* Make vertical navbar expand/collapse animation smoother (as it's currently impossible to disable it :-( )

* Fix misc UI issues (show Create label on expanded menu; fix some CSS; don't select items on click)

* Allow to override navbars with DynamicComponent

* Fix misc UI issues: expand/collapse button animation, menu items styles, menu expand/collapse animation

* Hide submenu arrow; show username when menu is expanded

* Refine CSS and make it more isolated; adjust colors

* Update tests

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-06-15 10:01:49 +03:00
Arik Fraimovich
d036df0ca1 V9 changelog (in master) (#4967)
* V9 Changelog: Initial Draft from Jesse

* V9 Changelog: Add later updates

* Adjust title spacing

* Apply Jesse's suggestions

Co-authored-by: Jesse <jesse@whitehouse.dev>

* provide an explanation on how to switch from Celery to RQ when upgrading to v9

* Update CHANGELOG.md

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>

* Add contributor names

* Update version.

* Update CHANGELOG

Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
Co-authored-by: Jesse <jesse@whitehouse.dev>
Co-authored-by: Omer Lachish <omer@rauchy.net>
2020-06-11 12:33:36 +03:00
Gabriel Dutra
56df870f39 Plotly Charts: use .legend to determine legends size (#4935)
* Plotly Charts: use .bg to determine legends size

* Test: remove hack for legend below plotly

* Revert "Test: remove hack for legend below plotly"

This reverts commit d8efb0c032.

* Use .legend to calculate bounds

* Also update plots without legend
2020-06-11 12:30:55 +03:00
Arik Fraimovich
05540164e1 Small updates to Dockerfile (#4964) 2020-06-11 12:28:59 +03:00
Gabriel Dutra
bdb62365b1 Fix Sankey issue to render 1 and 5 stages (#4965)
* Sankey: Make sure last stage has "Exit"

* Sankey: Use 2 as min stage width size to render

* Use null instead of "Exit"

* Add comment about corresponding exit node

* Add multiple stages on Cypress test
2020-06-11 12:28:45 +03:00
Gabriel Dutra
6a12168f40 Make sure page updates when 'routes' changes (#4962) 2020-06-09 15:43:23 -03:00
Gabriel Dutra
ac0b494953 Fix Destination not returning custom error message (#4959) 2020-06-09 11:14:29 -03:00
Gabriel Dutra
77e8d70a64 Make sure queries have options and parameters (#4952) 2020-06-09 11:13:42 -03:00
Rob Hudson
8597b727a7 Update requirements_bundles versions and comment (#4939) 2020-06-09 13:38:29 +02:00
Omer Lachish
e233611840 Fix wrong Sentry exception messages on invalid parameters (#4945)
* don't join underlying exception message with commas when invalid parameter errors happen

* Revert "don't join underlying exception message with commas when invalid parameter errors happen"

This reverts commit 71a21b7ce6.

* when a problem occurs during refresh_queries, report it as a RefreshQueriesError
2020-06-07 13:05:31 +03:00
Gabriel Dutra
dd6098d405 MongoDB: Only set readPreference when it has one (#4947)
* MongoDB: Only set readPreference when it has one

* Use .get for readPreference
2020-06-07 12:39:43 +03:00
Omer Lachish
d38d3b6b4d support comma-separated queue lists for backward compatability (spaces are still supported) (#4937) 2020-06-03 15:13:52 +03:00
Gabriel Dutra
100c7be5e0 Return cached data source schema when available (#4934) 2020-06-02 11:26:53 +03:00
chulucninh09
733bc1c109 fix strftime format notation for second and millisecond (#4922) 2020-05-31 22:23:42 +03:00
Saravanan Selvamohan
19cc7f1be8 Added correct usage of the article (#4911)
Add an article - the level
Correct article usage - a schema browser
Consider adding hyphen - ready-made
Removed comma - environment
2020-05-31 22:22:01 +03:00
Arik Fraimovich
43e5c2aa11 Fix: auto hide Plotly Modebar (#4930)
* Only set value for displayModeBar if we want to hide it

* Update viz-lib/src/visualizations/chart/Renderer/PlotlyChart.jsx
2020-05-31 21:18:52 +03:00
Gabriel Dutra
376b317e2e Update requests usages not to allow redirects (#4924)
* Update requests usages not to allow redirects

* Remove type from super()

Co-authored-by: Jannis Leidel <jannis@leidel.info>

Co-authored-by: Jannis Leidel <jannis@leidel.info>
2020-05-31 12:49:39 +03:00
Levko Kravets
d550427485 Fork button disabled on View Query page for non-admin users (#4927) 2020-05-29 11:41:07 +03:00
Ievgen Aleinikov
d1044c1963 Athena: set query cost (#4077) 2020-05-27 13:16:46 +03:00
Gabriel Dutra
46e18b0c6f Use memoized query result data (#4920) 2020-05-26 11:30:31 -03:00
Levko Kravets
38dd3ff248 Fix flaky Map (Markers) tests (#4915)
* Fix flaky Map (Markers) tests

* Fix flaky Choropleth tests
2020-05-26 10:57:08 +03:00
Gabriel Dutra
6bac19c1e4 Use Antd Descriptions on Details visualization (#4914)
* Use Antd Descriptions for Details visualization

* Update styling

* Add some spacing to pagination
2020-05-26 09:52:03 +03:00
Gabriel Dutra
ce6bc2d64a Update antd to v3.26.17 (#4913)
* Update antd to v3.26.17

* Remove custom bg color for danger button

* Update ScheduleDialog snapshot
2020-05-24 22:28:39 +03:00
Gabriel Dutra
27c4992003 Use lambda on options for destinations factory (#4912) 2020-05-24 22:22:01 +03:00
Gabriel Dutra
13e454de86 Update Query Page shortcuts for MacOS (#4910)
* Add Ctrl+Enter to run queries (for Mac)

* Check altKey and show as "Option" key when for Mac
2020-05-24 11:19:50 +03:00
Levko Kravets
f4c9d7db1a getredash/redash#4692 When resizing chart to a certain size it errors out (#4907) 2020-05-24 11:17:25 +03:00
Gabriel Dutra
0d11d7bec2 Change visualizations build to be on postinstall instead of preinstall (#4909) 2020-05-22 11:07:52 -03:00
Arik Fraimovich
ec68e8bba3 Fix: table viz crashing when search is enabled (#4899)
* Fix: table viz crashing when search is enabled

* Replace that weird hack with more controlled code

* Don't clear search input, apply search when data changes

Co-authored-by: Levko Kravets <levko.ne@gmail.com>
2020-05-21 11:13:36 +03:00
chulucninh09
831512e52d Fix: front-end error when parse python float nan (#4903)
* float nan or inf will be serialized as null instead of NaN

* Re-implement float nan serialization fix for consistency
2020-05-21 11:12:19 +03:00
Patrick Yang
dfc873fb8b Add additional statsd metrics for worker/scheduler (#4884)
* Add additional statsd metrics for worker/scheduler
2020-05-20 14:35:55 -07:00
koooge
b117485571 chore: Skip dev install in frontend testing (#4897)
Signed-off-by: koooge <koooooge@gmail.com>
2020-05-20 13:14:28 +03:00
Gianni Moschini
3661d6cbc5 Remove heroku bin/pre_compile file (#4900) 2020-05-20 13:02:39 +03:00
Jannis Leidel
a2217cc4ec Set the schema item title attribute correctly. (#4892)
Co-authored-by: Gabriel Dutra <nesk.frz@gmail.com>
2020-05-15 15:04:00 -03:00
Gabriel Dutra
a7ea94f69a Pin @percy/agent version and update Cypress (#4896) 2020-05-15 14:22:43 -03:00
Gabriel Dutra
8010781f0d Add private address check to BaseHTTPQueryRunner (#4885)
Co-authored-by: Arik Fraimovich <arik@arikfr.com>

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-05-14 09:56:22 +03:00
Gabriel Dutra
7c8874b8ee Fix HelpTrigger in header not working (#4887) 2020-05-12 12:56:04 -03:00
Arik Fraimovich
8907a86e33 Make frontend build in Docker image optional (#4879)
* Add build arg to Dockerfile to control if we should build frontend assets

* Move more env settings into the shared one.

* Use build arg in docker-compose to skip frontend build.

* CirlceCI: Skip building frontend assets in backend tests

* Create dummy template files

* Expand file names manually.

* Add build arg to skip dev dependencies.

* Update Dockerfile

* Reverse logic of skip_dev_deps to what it should be.
2020-05-12 16:46:53 +03:00
Gabriel Dutra
22f0030864 Add release to html webpack config (#4883)
Co-authored-by: Arik Fraimovich <arik@arikfr.com>

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-05-12 10:55:48 +03:00
Gabriel Dutra
baf16d2501 Oracle: Encoding fix (#4882)
* Oracle: Encoding fix

Co-authored-by: Arik Fraimovich <arik@arikfr.com>

* Update redash/query_runner/oracle.py

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-05-12 10:54:32 +03:00
Gabriel Dutra
0446080d3f Yandex Metrica: rename .host to .url. (#4880)
Co-authored-by: Arik Fraimovich <arik@arikfr.com>

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-05-12 10:42:07 +03:00
Arik Fraimovich
a8a2964cb0 Make the Databricks driver URL and environment variable (#4878)
* Make the Databricks driver URL and environment variable

* Replace ENV with ARG
2020-05-11 13:24:11 +03:00
Omer Lachish
9562718a6a Run queries through ad-hoc SSH tunnels (#4797)
* run queries through adhoc SSH tunnels

* reduce indent by losing try/else clause

* document host/port getters and setters

* handle forceful schema refreshes in RQ and poll for their results using the /jobs endpoint

* set schema refresh timeout to 5 minutes

* Restyled by prettier (#4847)

Co-authored-by: Restyled.io <commits@restyled.io>

* send schema refresh errors as part of API response

* Use correct get_schema call.

Co-authored-by: restyled-io[bot] <32688539+restyled-io[bot]@users.noreply.github.com>
Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-05-11 13:22:40 +03:00
Takuya Arita
e470347d7f Refactor docker-compose.yml for development (#4544)
* Update description for the new setup repository

* Refactor docker-compose.yml for development

* Use Docker Compose file v2
2020-05-11 13:01:32 +03:00
Gabriel Dutra
76aeab02eb Postgres: Add support for uploading SSL certs (#4871)
Co-authored-by: Arik Fraimovich <arik@arikfr.com>

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-05-08 09:42:00 +03:00
Gabriel Dutra
9568c74fd0 Sentry's Performance tracing (#4872)
* Upgrade sentry-sdk

Co-authored-by: Arik Fraimovich <arik@arikfr.com>

* Move traces sample rate to configuration

Co-authored-by: Arik Fraimovich <arik@arikfr.com>

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-05-08 09:40:20 +03:00
Gabriel Dutra
57287b2c0b Fix: there is no host field. (#4873)
Co-authored-by: Arik Fraimovich <arik@arikfr.com>

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-05-08 09:39:22 +03:00
Gabriel Dutra
6d857588a1 Log data source id on errors (#4874)
Co-authored-by: Omer Lachish <omer@rauchy.net>

Co-authored-by: Omer Lachish <omer@rauchy.net>
2020-05-08 08:59:06 +03:00
Levko Kravets
dc49585320 Add option to explicitly set chart legend position (#4865)
* Add option to explicitly set chart legend position

* Revert some chanes in order to fix tests

* Leave only "auto" and "below the plot" legend placement options

* Move Show legend checkbox to select; fix spelling
2020-05-07 17:21:31 +03:00
Gabriel Dutra
fc246aafc4 Separate visualizations into their own package (#4837)
* Add visualizations project settings

* Move visualizations to redash-visualizations

* Delete shared components

* Remove antd from deps

* Remove p-r-5 from table utils

* Remove visualization deps from package.json

* Rename package and change its version

* Test preinstall script

* Update Dockerfile build for frontend

* Test adding dockerignore

* Update jest tests

* Add step for jest tests

* Include viz-lib on dev commands

* User prettier v1 for now

* Delete unused libs on the app

* Add readme draft (to be finished)

* Add getOptions to Editor

* Add required libraries and finish basic example

* Bump version
2020-05-06 10:49:15 +03:00
Gabriel Dutra
4f8d2caed4 Cypress: Add tests for Filters (#4757) 2020-05-05 01:12:01 -03:00
Levko Kravets
27eab28405 Search in navbar works only for first search term (#4857)
* Queries list page: react on search term change (from navbar)

* Items List components: update search input value when changed "outside"

* Code style
2020-05-04 15:05:49 +03:00
Reynold Xin
8a9a2e7199 Minor tweak to README (#4862) 2020-05-04 10:23:02 +03:00
Reynold Xin
8d29e80013 A new intro paragraph to explain what Redash is (#4861)
* A new intro paragraph to explain what Redash is

We have been using Redash at Databricks and really love it. I took the time to work with Arik to create a better, more up-to-date description of the project.

* Add data sources
2020-05-03 23:43:11 +03:00
Gabriel Dutra
0e3d25c40c Fix visualizations with filters not showing selected values (#4854) 2020-05-03 12:37:23 -03:00
Arik Fraimovich
fdc4205774 Add blob: to allowed img-src rules in CSP (#4860)
This is needed for Plotly download PNG feature to work.
2020-05-03 12:32:17 +03:00
Arik Fraimovich
873c87b4b3 Fix: showing current settings tab broken in MULTI_ORG. (#4855)
* Fix: showing current settings tab broken in MULTI_ORG.

* Revert "Fix: showing current settings tab broken in MULTI_ORG."

This reverts commit a88defd0b5.

* Add test for SettingsMenu#isActive

* Use stripBase to remove slug from url
2020-05-02 14:45:54 +03:00
Gabriel Dutra
ae9bbe25e5 Cypress: Assert results keep up on widget refresh (#4846) 2020-04-30 12:20:04 +03:00
koooge
e3fff396cb chore: Update node8 to 12 (#4845)
Signed-off-by: koooge <koooooge@gmail.com>
2020-04-30 12:19:06 +03:00
Gabriel Dutra
f37e3d5a10 Fix dashboard not showing results while refreshing (#4840) 2020-04-29 21:09:35 +03:00
Gabriel Dutra
45e1478be3 Specify restylers versions for restyled (#4842)
* Specify restylers versions for restyled

* Trigger file change for testing

* Revert "Trigger file change for testing"

This reverts commit d203e37700.
2020-04-29 15:44:57 +03:00
Jannis Leidel
2c90d920b3 Add ability to ship periodic RQ jobs as part of extensions again. (#4822)
This was dropped in aa17681af2.
2020-04-28 18:39:30 +02:00
Gabriel Dutra
bb767f3747 Remove Helper Classes from visualizations (#4788) 2020-04-25 15:51:21 -03:00
Gabriel Dutra
60bc1f8e35 Visualizations customizable settings (#4793) 2020-04-25 12:33:42 -03:00
koooge
de6d665c6e fix: Make sure boto installed (#4835)
Signed-off-by: koooge <koooooge@gmail.com>
2020-04-25 12:34:45 +03:00
Arik Fraimovich
60f92a2efc Add column description to table viz (#4831)
* Add column description to table viz

* Fix: misplaced super long titles tooltip.
2020-04-24 18:50:45 +03:00
Arik Fraimovich
ea8a075a2d ODBC Based Databricks Connector (#4814)
* ODBC Based Databricks connector.

* Install Databricks' ODBC driver in Docker image

* Add useragent string.

* Add Types enum to redash.query_runner to replace the seprate constants.

* Databricks connector:

1. Parse types.
2. Send additional connection options.
3. Correctly parse errors.

* Switch to TYPE constants to use code with Python 2.

* Add note about the Databricks driver terms and conditions.

* Show message about Databricks driver terms and conditions.

* Handle cases when the query doesn't return any results.

* Update redash/query_runner/databricks.py

Co-Authored-By: Jesse <jesse@whitehouse.dev>

* Use new Databricks logo

* Fix connection string options

Co-authored-by: Jesse <jesse@whitehouse.dev>
2020-04-24 18:04:44 +03:00
Arik Fraimovich
6ee9b43ef9 Show explicit user name instead of avatar in lists. (#4828) 2020-04-24 17:32:45 +03:00
Arik Fraimovich
cfc82156c2 Reduce number of queries used to load the dashboards list (#4816)
* Reduce number of queries used to load the dashboards list.

* Use DashboardSerializer everywhere.

* Call serialize
2020-04-21 10:07:48 +03:00
Omer Lachish
ab6dc51540 reset is_invitation_pending if someone tries to login through a reset passwrod link for the first time (#4817) 2020-04-20 20:39:08 +03:00
James T. Boylan
70186ab835 Dashboard Search bug fix (#4804)
* Move Dashboard off `subqueryload()` loader in all() method due to inconsistent results bug in SQLAlchemy when leveraging distinct within a subqueryload call through paginate.

* Added source reference to Presto Query Runner connection through the pyhive client to announce to presto that the query is coming from `redash` instead of `pyhive`.

* Removing source line from presto query runner to refactor based on feedback.
2020-04-19 21:46:25 +03:00
Matt N
e99c37a36a Don't immediately remove notifications from notification trays (#4773)
Let the notifications go into browser/OS notification trays so users can click on them from there if they miss the initial notification. Modern browsers generally use OS notifications so the user is in control of the notification at the OS level.
2020-04-14 14:27:03 +03:00
Cemre Mengu
de40f1a07b Fix comparison error when scale is None (#4638)
* Fix comparison error when scale is None

Prevents `'>' not supported between instances of 'NoneType' and 'int'` error when scale is `None`

* Update oracle.py

* Fix scale logic.

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-04-14 13:36:12 +03:00
Arik Fraimovich
2c1eb5c10d Disable Percy snapshot for Choropleth test (#4799)
* Disable Percy snapshot for Choropleth test

* Increase wait.

* Diasble Percy snapshot.

* Reduce wait time to original value.

* Restyled by prettier (#4800)

Co-authored-by: Restyled.io <commits@restyled.io>

* Update choropleth_spec.js

Co-authored-by: restyled-io[bot] <32688539+restyled-io[bot]@users.noreply.github.com>
Co-authored-by: Restyled.io <commits@restyled.io>
2020-04-14 13:34:35 +03:00
Daniel Lang
02cf895983 Added setting to hide Plotly mode bar (#4644) 2020-04-14 13:08:17 +03:00
Stefan Mees
940bd564d7 Datasource Exasol: support encryption setting (#4712)
* add pyexasol datasource, ensure that integer dont overflow in javascript

* support setting encryption for Exasol connections

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-04-14 12:38:01 +03:00
Jesse
9ba57a9491 Adds option to show 500 rows in the table visualization. Previous max (#4770)
was 250.
2020-04-14 12:36:27 +03:00
Omer Lachish
b80abd11fb use total_seconds to find stale jobs (#4777) 2020-04-14 11:49:17 +03:00
Levko Kravets
1d4ca5cf2e Pie Chart: set contrast colors for text in sectors (#4783) 2020-04-14 11:48:54 +03:00
Weng Kham
f7df6e0cdc Fix test connection on mongodb datasource (#4785)
Co-authored-by: Weng Kham Kan <wengkham.kan@icarasia.com>
2020-04-14 11:47:14 +03:00
Gabriel Dutra
3df1a86d66 Fix param added with empty query ignores options (#4736) 2020-04-14 11:41:52 +03:00
Gabriel Dutra
bad1294402 Dashboard Performance: Memoize widgets (#4734) 2020-04-14 11:04:39 +03:00
Gabriel Dutra
3d26afef16 Move the dropdown to the side in Edit Mode (#4758) 2020-04-14 10:37:06 +03:00
Logan Price
2d29240195 feature: add ability to make the restriction of api calls to private addresses optional (#4790)
* feature: add ability to make the restriction of api calls to private addresses optional

* chore: fix typo

* Update redash/settings/__init__.py

Co-authored-by: lprice92 <lprice92@iastate.edu>
Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-04-14 10:34:13 +03:00
Gabriel Dutra
c698359cb8 Remove "context" prop from visualizations (#4789) 2020-04-13 10:17:33 -03:00
Gabriel Dutra
2b3d9053e9 Fix Multi-Filters: "select all" makes table view unscrollable (#4782)
* Limit filters to 40% of query fixed layout space

* Add check for height to determine fixed layout

* Add maxTagCount of 5 to Filters

* Update maxTagCount settings to be similar to Parameters
2020-04-13 15:13:28 +03:00
Atharva Inamdar
45ea5171cb 4791 redshift schema bugfix (#4792)
* #4791 exclude pg_ tables from redshift table schema inspection

* restrictict only pg_temp
2020-04-12 13:56:06 +03:00
Omer Lachish
6a5445b726 sent stack trace to Sentry when refresh_queries fails to enqueue a certain query (#4780) 2020-04-08 16:34:36 +03:00
Levko Kravets
51b573230f Upgrade Plotly (#4752)
* Upgrade Plotly

* Fixes to Plotly wrapper

* Decrease plot margins

* Adjust plot margins
2020-04-06 13:35:39 +03:00
Levko Kravets
54b04eaff7 Pie chart ignores series labels (#4775) 2020-04-06 13:31:58 +03:00
Georgi Staykov
1e96faed3b Add db thread pool option to keep idle connections alive (#4741)
* Add db thread pool option to keep idle connections alive

* Add SQLALCHEMY_ENABLE_POOL_PRE_PING setting

* Change SQLALCHEMY_ENABLE_POOL_PRE_PING default value to false.

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-04-05 14:13:20 +03:00
Arik Fraimovich
90bfba57d4 Fix: extendedEnum breaks JSONSchema parsing (#4774)
(probably due to Python 3 migration)
2020-04-03 12:07:25 +03:00
lihan
7f2a0af841 Removing the PIP cache from the built image (#4766) 2020-04-03 12:06:55 +03:00
Arihant Surana
f9e3ac7534 feat: Add ssl options for Cassandra data source (#4665)
* feat: provide ssl options for Cassandra data source

* remove Log and prints

* Refactor to create module methods and unit tests

* Switch to using Enumerator and temp file

* Fix temporary file lifecycle for cert

* Align with changes on master

* Fix non certificate but ssl enabled usecase
2020-04-03 11:03:47 +03:00
Gabriel Dutra
4d266176d0 Fix parameter spec flaky test (#4771) 2020-04-02 16:01:22 -03:00
Levko Kravets
3373cfc1eb Sankey diagram should occupy all available area (not just the left part) (#4765)
* Code style

* Remove dead and duplcated code

* getredash/redash#4763 Sankey diagram should occupy full available area (not just the left part)
2020-03-31 23:10:27 +03:00
Gabriel Dutra
e3745f8ba3 Fix there's no publish button on mobile query page (#4760) 2020-03-30 18:16:36 -03:00
Arik Fraimovich
3f6699032f Add apt update step in build docker image job. 2020-03-24 16:11:49 +02:00
Gabriel Dutra
adf8b2e42b Cypress: Add/Edit query and dashboard tags spec (#4744) 2020-03-24 16:03:22 +02:00
Gabriel Dutra
8db1612689 Fix query based param with no results crashing page (#4707)
* Fix query based param with no results crashing page

* Add message for empty dropdown parameters

* Handle 500 no results case with empty result set

* Cypress: Make sure it shows the message

* Use .ant-select-selection to open dropdown
2020-03-24 14:48:14 +02:00
Gabriel Dutra
fabaf73b7b Move data source/destination deprecated handling to frontend (#4753)
* Move DS deprecated handling to frontend

* Add Cypress assertion for deprecated types
2020-03-24 10:09:03 +02:00
Ezekiel Templin
45914f941f Set POSTGRES_HOST_AUTH_METHOD environment variable (#4740)
Redash's docker-compose file will no longer bring up an environment from
a cold start due to recent upstream changes to the postgres image that
force the user to either set a password for the default superuser or
opt-in to allowing all connections without a password via environment
variable.

Upstream PR: https://github.com/docker-library/postgres/pull/658
Related Discussion: https://github.com/docker-library/postgres/issues/681
2020-03-18 14:52:23 +02:00
Gabriel Dutra
1e9b8f1126 Fix no button to add query tags (#4737) 2020-03-17 22:11:33 +02:00
Levko Kravets
52911b7be3 Cohort appearance settings (#4597)
* Cohort: add settings for tooltips, value formats and placeholder

* Cohort: add settings for colors

* Cohort: change all settings tabs to use horizontal inputs

* Cohort: show color labels in editor
2020-03-17 13:42:45 +02:00
Levko Kravets
a10a3f1731 getredash/redash#4728 DOMPurify by default removes 'target' attribute (#4729) 2020-03-17 13:30:55 +02:00
Gabriel Dutra
33131c1354 Trigger CI lint failure on warnings and fix failing frontend unit tests (#4735)
* Trigger lint error on warnings on CI

* Test removing pip3 command from frontend unit

* Test eslint warning

* Revert "Test eslint warning"

This reverts commit 89d407345a.

* Revert "Test removing pip3 command from frontend unit"

This reverts commit 424c900200.

* Run apt update before installing pip3
2020-03-16 13:27:21 +02:00
Gabriel Dutra
f6750428cf Dashboard Performance: HtmlContent improvements (#4726)
* Dashboard Performance: Memoize HtmlContent

* Only render HtmlContent if there is a description
2020-03-15 15:12:50 +02:00
Gabriel Dutra
f4b69d4495 Cypress: Separate start command and update to v4.1.0 (#4690) 2020-03-11 16:14:33 -03:00
Levko Kravets
db71ff399c Refactor dialog wrapper component (#4594)
* Dialog wrapper: stop using promises to handle results - replace with callbacks

* Dialog wrapper: handle async operation on close
2020-03-10 22:22:42 +02:00
Levko Kravets
e552effd96 Remove route.resolve feature (#4607)
* Stop using route.resolve feature (pages should load all the data themselves)

* Remove route.resolve feature

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-03-10 13:09:26 +02:00
Satyam Krishna
75cc6b3f53 Fix : Alembic migration for scheduled query from older to newer version (#4709) 2020-03-08 17:41:21 +02:00
Gabriel Dutra
bf3095c794 Update Dashboard and Alert headers with the Query one (#4710) 2020-03-06 14:26:37 -03:00
Omer Lachish
ee6dcab362 Cancel BigQuery Queries (#4701)
* cancel BigQuery queries when user requests cancellation or when the job times out

* create a new bigquery client to flush exising requests
2020-03-04 22:45:20 +02:00
Gabriel Dutra
e0312fb717 Mobile experience improvements (#4694)
* Allow touch action on dashboard grid

* Deactivate touch when resizing widgets

* Disable touch interactions on Plotly

* Update Plotly and use dragmode: false

* Remove autoFocus from ItemsList search

* Fix spacing for queries and dashboard favorites

* Make sure admin pages don't go over 100% width
2020-03-04 12:55:51 +02:00
Omer Lachish
791a0b3ec7 allow comparison with strings containing numbers as alert values (#4705) 2020-03-04 12:40:23 +02:00
Anton Yuzhaninov
e03e58c5c7 Fix: show size of actually used Redash database (#4706)
'postgres' is a default database name in the Docker image, but if an
external database server is used, than Redash database can have
a different name (specified in REDASH_DATABASE_URL).
2020-03-03 21:21:56 +02:00
Arik Fraimovich
78201c6108 Dynamic Form: boolean fields related fixes (#4586)
* Fix: when default value is false make sure it's still stringified.

* Fix: when extra field is of type boolean make sure it's different from default value to decide if it's open.

* Use isNil to check the default value

* Restyled by prettier (#4704)

Co-authored-by: restyled-io[bot] <32688539+restyled-io[bot]@users.noreply.github.com>
2020-03-03 12:55:54 +02:00
Arik Fraimovich
d687befa59 Query page: update empty state text (#4699) 2020-03-03 10:53:45 +02:00
Gabriel Dutra
9635d00476 Fix error for query snippets with empty description (#4693) 2020-03-01 15:05:44 -03:00
Jesse
418590003e Solves redashlabs/product#47 (#4669) 2020-03-01 14:19:00 +02:00
Levko Kravets
3650f0c45b Table visualization: Show which columns are being used for search (#4680)
* Table visualization: Show which columns are being used for search

* Fix accidental bug
2020-03-01 14:15:49 +02:00
erels
668403c126 Fixed Clickhouse column name encoding problem (#4682) 2020-03-01 14:11:49 +02:00
Arik Fraimovich
4b94a5c88f Snowflake: use different method of showing columns if no schema specified in db name (#4696)
* Snowflake: use different method of showing columns if no schema specified in db name

* Update redash/query_runner/snowflake.py

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

* Update redash/query_runner/snowflake.py

Co-authored-by: Omer Lachish <omer@rauchy.net>
2020-03-01 13:55:28 +02:00
Omer Lachish
a9cb87d4b3 refresh_queries shouldn't break because of a single query having a bad schedule object (#4163)
* move filtering of invalid schedules to the query

* simplify retrieved_at assignment and wrap in a try/except block to avoid one query blowing up the rest

* refactor refresh_queries to use simpler functions with a single responsibility and add try/except blocks to avoid one query blowing up the rest

* avoid blowing up when job locks point to expired Job objects. Enqueue them again instead

* there's no need to check for the existence of interval - all schedules have intervals

* disable faulty schedules

* reduce FP style in refresh_queries

* report refresh_queries errors to Sentry (if it is configured)

* avoid using exists+fetch and use exceptions instead
2020-03-01 11:02:46 +02:00
Omer Lachish
b0f1cdd194 remove rq_healthcheck entrypoint and deprecate celery_healthcheck (#4574) 2020-02-27 17:55:04 +02:00
juanvasquezreyes
5d533a3277 Oracle: update DSN construction to support special characters in user/password. (#4659)
* Update oracle.py

The reason I propose this change is to fix an issue when oracle password has an @
example of connection string: user/p@ssword@host

* Update oracle.py

Fixing init after comments

* Remove empty constructor.

Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-02-27 17:50:46 +02:00
Gabriel Dutra
5fa5cd958b Change visualization editor scroll to internal divs (#4689) 2020-02-26 23:09:53 +02:00
Levko Kravets
c5f14e5538 Use main react-grid-layout package instead of fork (#4687) 2020-02-26 21:20:30 +02:00
Levko Kravets
7043951f00 Use npm ci instead of npm install in CI scripts (#4688) 2020-02-26 19:23:32 +02:00
Omer Lachish
9790b0731d Perform cleanup on job timeouts (#4681)
* move repeated query cancellation error messages to the job serializer

* oerform cleanup on JobTimeoutException and DRY query cancellation exception blocks

* import JobTimeoutException directly from rq

* fix syntax error introduced by mistake

* add missing import
2020-02-26 13:24:57 +02:00
Levko Kravets
3102e2df94 Fix: Chart with a horizontal legend sometimes doesn't render properly (#4683) 2020-02-25 13:07:17 +02:00
Gabriel Dutra
f396c96457 Merge branch 'master' into query-based-dropdown--parameters 2020-02-25 07:49:36 -03:00
Omer Lachish
35250d64b9 Job timeout doesn't kill the mysql query (#4629)
* forward timeout SIGALRMs to MySQL threads in order to kill any running proccesses

* no need to attach to SIGALRM as RQ already does that
2020-02-25 00:16:19 +02:00
Gabriel Dutra
cdfa102125 Query View page design adjustments (#4670)
* Realign Data Source and Refresh Schedule

* Adjust execution status height

* Rewrite Query Page Header flexibility

* Remove margin from QuerySource parameters

* Cypress: Visit visualization instead of click in tab

* Fix wrong css class name in dashboard-grid
2020-02-24 21:05:58 +02:00
Gabriel Dutra
8bfcbf21e3 Remove redundant import 2020-02-24 11:44:28 -03:00
Gabriel Dutra
8a1640c4e7 Separate InputPopover component 2020-02-24 11:44:18 -03:00
Gabriel Dutra
209ee16261 Fix verification email url on home page (#4647) 2020-02-24 10:15:35 -03:00
Arik Fraimovich
f1a2f8cb88 Enable ODBC and MS SQL ODBC support (#4676)
Closes #4356.
2020-02-23 11:57:39 +02:00
Arik Fraimovich
dd8e23040a Remove core-js and polyfill include as we don't use Phantom anymore (#4583) 2020-02-23 11:15:53 +02:00
Gabriel Dutra
a37e7f93dc Add is_safe test for queries with params 2020-02-22 15:47:29 -03:00
Gabriel Dutra
cc34e781d3 Small updates
- Change searchTerm separator
- Add cy.wait
2020-02-22 15:23:43 -03:00
Gabriel Dutra
6aa0ea715e Invert tooltip messages order 2020-02-22 14:08:19 -03:00
Gabriel Dutra
6c27619671 Make Parameter Mapping required in UI 2020-02-21 23:00:26 -03:00
Gabriel Dutra
6eeb3b3eb2 Separate UI components 2020-02-21 15:49:23 -03:00
Gabriel Dutra
d40edb81c2 Fix backend tests 2020-02-21 14:18:59 -03:00
Gabriel Dutra
f128b4b85f Only allow search for Text Parameters 2020-02-21 13:36:06 -03:00
Gabriel Dutra
264fb5798d Merge branch 'master' into query-based-dropdown--parameters 2020-02-21 13:31:49 -03:00
Gabriel Dutra
90023ac435 Make sure Table updates correctly 2020-02-21 11:03:37 -03:00
Gabriel Dutra
df755fbc17 Add try except for NoResultFound 2020-02-21 09:40:52 -03:00
Gabriel Dutra
e555642844 Add is_safe check for parameterized query based 2020-02-21 09:27:12 -03:00
Gabriel Dutra
bdd7b146ae Change stored mapping attributes 2020-02-20 21:59:19 -03:00
Gabriel Dutra
b7478defec Don't validade query params with params 2020-02-20 19:29:43 -03:00
Gabriel Dutra
bb0d7830c9 Fixes + temp remove validation for Query param 2020-02-20 18:49:29 -03:00
Gabriel Dutra
d2cc2d20b6 Query Editor: Remove overflow visible from visualization (#4668) 2020-02-20 18:16:26 -03:00
Levko Kravets
7f8b103aea getredash/redash#4666 The download button on the dashboard will redirect users to an invalid page (#4667) 2020-02-20 21:16:16 +02:00
Gabriel Dutra
9eaa44da4a Query View redesign (#4536)
Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2020-02-19 17:47:34 -03:00
Gabriel Dutra
2833bb539f Fix Add Widget always shows recent queries list (#4658) 2020-02-18 18:01:26 -03:00
Gabriel Dutra
137aa22dd4 Parameter Mapping UI (2/2) 2020-02-18 17:55:27 -03:00
Levko Kravets
7ff5af1bf5 getredash/redash#4655 Closing the Help Drawer redirects you the homepage (#4657) 2020-02-18 21:23:23 +02:00
Omer Lachish
7124bc91d7 Avoid timing out when no timeout is set (#4653)
* soft limits should not exceed if they are set to run infinitely

* use a variable to explain magic number
2020-02-18 15:29:33 +02:00
Gabriel Dutra
9cf396599a Parameter Mapping UI (1/2) 2020-02-17 23:32:10 -03:00
Gabriel Dutra
b70f0fa921 Iterate over backend verification 2020-02-16 13:39:05 -03:00
Omer Lachish
abbfd598d7 support relative time in cloudwatch queries (#4649) 2020-02-16 12:23:25 +02:00
Gabriel Dutra
5e3613d6cb Start experiements with a 'search' parameter 2020-02-13 16:29:50 -03:00
Gabriel Dutra
545da898ee Fix dashboard editing permissions not working (#4613)
* Use dashboard.can_edit instead of checking owner

* Add owner or admin check to Manage Permissions

* Remove unnecessary useMemo
2020-02-13 11:50:45 +02:00
785 changed files with 49924 additions and 15011 deletions

View File

@@ -1,12 +1,12 @@
FROM cypress/browsers:chrome67 FROM cypress/browsers:node14.0.0-chrome84
ENV APP /usr/src/app ENV APP /usr/src/app
WORKDIR $APP WORKDIR $APP
COPY package.json $APP/package.json COPY package.json package-lock.json $APP/
RUN npm run cypress:install > /dev/null COPY viz-lib $APP/viz-lib
RUN npm ci > /dev/null
COPY client/cypress $APP/client/cypress COPY . $APP
COPY cypress.json $APP/cypress.json
RUN ./node_modules/.bin/cypress verify RUN ./node_modules/.bin/cypress verify

View File

@@ -2,10 +2,11 @@ version: 2.0
build-docker-image-job: &build-docker-image-job build-docker-image-job: &build-docker-image-job
docker: docker:
- image: circleci/node:8 - image: circleci/node:12
steps: steps:
- setup_remote_docker - setup_remote_docker
- checkout - checkout
- run: sudo apt update
- run: sudo apt install python3-pip - run: sudo apt install python3-pip
- run: sudo pip3 install -r requirements_bundles.txt - run: sudo pip3 install -r requirements_bundles.txt
- run: .circleci/update_version - run: .circleci/update_version
@@ -32,7 +33,7 @@ jobs:
name: Build Docker Images name: Build Docker Images
command: | command: |
set -x set -x
docker-compose build --build-arg skip_ds_deps=true docker-compose build --build-arg skip_ds_deps=true --build-arg skip_frontend_build=true
docker-compose up -d docker-compose up -d
sleep 10 sleep 10
- run: - run:
@@ -56,25 +57,37 @@ jobs:
- store_artifacts: - store_artifacts:
path: coverage.xml path: coverage.xml
frontend-lint: frontend-lint:
environment:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:8 - image: circleci/node:12
steps: steps:
- checkout - checkout
- run: mkdir -p /tmp/test-results/eslint - run: mkdir -p /tmp/test-results/eslint
- run: npm install - run: npm ci
- run: npm run lint:ci - run: npm run lint:ci
- store_test_results: - store_test_results:
path: /tmp/test-results path: /tmp/test-results
frontend-unit-tests: frontend-unit-tests:
environment:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:8 - image: circleci/node:12
steps: steps:
- checkout - checkout
- run: sudo apt update
- run: sudo apt install python3-pip - run: sudo apt install python3-pip
- run: sudo pip3 install -r requirements_bundles.txt - run: sudo pip3 install -r requirements_bundles.txt
- run: npm install - run: npm ci
- run: npm run bundle - run: npm run bundle
- run: npm test - run:
name: Run App Tests
command: npm test
- run:
name: Run Visualizations Tests
command: (cd viz-lib && npm test)
- run: npm run lint - run: npm run lint
frontend-e2e-tests: frontend-e2e-tests:
environment: environment:
@@ -83,23 +96,45 @@ jobs:
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA== PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:8 - image: circleci/node:12
steps: steps:
- setup_remote_docker - setup_remote_docker
- checkout - checkout
- run:
name: Enable Code Coverage report for master branch
command: |
if [ "$CIRCLE_BRANCH" = "master" ]; then
echo 'export CODE_COVERAGE=true' >> $BASH_ENV
source $BASH_ENV
fi
- run: - run:
name: Install npm dependencies name: Install npm dependencies
command: | command: |
npm install npm ci
- run: - run:
name: Setup Redash server name: Setup Redash server
command: | command: |
npm run cypress start npm run cypress build
npm run cypress start -- --skip-db-seed
docker-compose run cypress npm run cypress db-seed docker-compose run cypress npm run cypress db-seed
- run: - run:
name: Execute Cypress tests name: Execute Cypress tests
command: npm run cypress run-ci command: npm run cypress run-ci
- run:
name: "Failure: output container logs to console"
command: |
docker-compose logs
when: on_fail
- run:
name: Copy Code Coverage results
command: |
docker cp cypress:/usr/src/app/coverage ./coverage || true
when: always
- store_artifacts:
path: coverage
build-docker-image: *build-docker-image-job build-docker-image: *build-docker-image-job
build-preview-docker-image: *build-docker-image-job build-preview-docker-image: *build-docker-image-job
workflows: workflows:

View File

@@ -1,4 +1,4 @@
version: '3' version: '2.2'
services: services:
redash: redash:
build: ../ build: ../

View File

@@ -1,7 +1,20 @@
version: '3' version: "2.2"
x-redash-service: &redash-service
build:
context: ../
args:
skip_dev_deps: "true"
skip_ds_deps: "true"
code_coverage: ${CODE_COVERAGE}
x-redash-environment: &redash-environment
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
REDASH_RATELIMIT_ENABLED: "false"
REDASH_ENFORCE_CSRF: "true"
services: services:
server: server:
build: ../ <<: *redash-service
command: server command: server
depends_on: depends_on:
- postgres - postgres
@@ -9,29 +22,25 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
<<: *redash-environment
PYTHONUNBUFFERED: 0 PYTHONUNBUFFERED: 0
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
REDASH_RATELIMIT_ENABLED: "false"
scheduler: scheduler:
build: ../ <<: *redash-service
command: scheduler command: scheduler
depends_on: depends_on:
- server - server
environment: environment:
REDASH_REDIS_URL: "redis://redis:6379/0" <<: *redash-environment
worker: worker:
build: ../ <<: *redash-service
command: worker command: worker
depends_on: depends_on:
- server - server
environment: environment:
<<: *redash-environment
PYTHONUNBUFFERED: 0 PYTHONUNBUFFERED: 0
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
cypress: cypress:
ipc: host
build: build:
context: ../ context: ../
dockerfile: .circleci/Dockerfile.cypress dockerfile: .circleci/Dockerfile.cypress
@@ -41,11 +50,13 @@ services:
- scheduler - scheduler
environment: environment:
CYPRESS_baseUrl: "http://server:5000" CYPRESS_baseUrl: "http://server:5000"
CYPRESS_coverage: ${CODE_COVERAGE}
PERCY_TOKEN: ${PERCY_TOKEN} PERCY_TOKEN: ${PERCY_TOKEN}
PERCY_BRANCH: ${CIRCLE_BRANCH} PERCY_BRANCH: ${CIRCLE_BRANCH}
PERCY_COMMIT: ${CIRCLE_SHA1} PERCY_COMMIT: ${CIRCLE_SHA1}
PERCY_PULL_REQUEST: ${CIRCLE_PR_NUMBER} PERCY_PULL_REQUEST: ${CIRCLE_PR_NUMBER}
COMMIT_INFO_BRANCH: ${CIRCLE_BRANCH} COMMIT_INFO_BRANCH: ${CIRCLE_BRANCH}
COMMIT_INFO_MESSAGE: ${COMMIT_INFO_MESSAGE}
COMMIT_INFO_AUTHOR: ${CIRCLE_USERNAME} COMMIT_INFO_AUTHOR: ${CIRCLE_USERNAME}
COMMIT_INFO_SHA: ${CIRCLE_SHA1} COMMIT_INFO_SHA: ${CIRCLE_SHA1}
COMMIT_INFO_REMOTE: ${CIRCLE_REPOSITORY_URL} COMMIT_INFO_REMOTE: ${CIRCLE_REPOSITORY_URL}

View File

@@ -6,11 +6,11 @@ docker login -u $DOCKER_USER -p $DOCKER_PASS
if [ $CIRCLE_BRANCH = master ] || [ $CIRCLE_BRANCH = preview-image ] if [ $CIRCLE_BRANCH = master ] || [ $CIRCLE_BRANCH = preview-image ]
then then
docker build -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/redash:preview
docker push redash/preview:$VERSION_TAG docker push redash/preview:$VERSION_TAG
else else
docker build -t redash/redash:$VERSION_TAG . docker build --build-arg skip_dev_deps=true -t redash/redash:$VERSION_TAG .
docker push redash/redash:$VERSION_TAG docker push redash/redash:$VERSION_TAG
fi fi

View File

@@ -1,6 +1,7 @@
client/.tmp/ client/.tmp/
client/dist/ client/dist/
node_modules/ node_modules/
viz-lib/node_modules/
.tmp/ .tmp/
.venv/ .venv/
venv/ venv/

3
.gitignore vendored
View File

@@ -5,11 +5,12 @@ venv/
.coveralls.yml .coveralls.yml
.idea .idea
*.pyc *.pyc
.nyc_output
coverage
.coverage .coverage
coverage.xml coverage.xml
client/dist client/dist
.DS_Store .DS_Store
celerybeat-schedule*
.#* .#*
\#*# \#*#
*~ *~

View File

@@ -50,11 +50,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
include: include:
- redash - redash
- tests - tests
- migrations/versions - migrations/versions
- name: prettier - name: prettier
image: restyled/restyler-prettier:v1.19.1-2
include: include:
- client/app/**/*.js - client/app/**/*.js
- client/app/**/*.jsx - client/app/**/*.jsx

View File

@@ -1,5 +1,149 @@
# Change Log # Change Log
## v9.0.0-beta - 2020-06-11
This release was long time in the making and has several major changes:
- Our backend code was updated to support Python 3 and we no longer support Python 2. If you're using our Docker images, this should be a transparent change for you.
- We replaced Celery with RQ for background jobs processing. This will require some setup updates -- see instructions below.
- The frontend code is now 100% React and we removed all the Angular dependencies.
This release was made possible by contributions from over 50 people: @ari-e, @ariarijp, @arihantsurana, @arikfr, @atharvai, @cemremengu, @chulucninh09, @citrin, @daniellangnet, @DavidHernandez, @deecay, @dmudro, @erans, @erels, @ezkl, @gabrieldutra, @gstaykov, @ialeinikov, @ikenji, @Jakdaw, @jezdez, @juanvasquezreyes, @koooge, @kravets-levko, @kykrueger, @leibowitz, @leosunmo, @lihan, @loganprice, @mickeey2525, @mnoorenberghe, @monicagangwar, @NicolasLM, @p-yang, @Ralnoc, @ranbena, @randyzwitch, @rauchy, @rxin, @saravananselvamohan, @satyamkrishna, @shinsuke-nara, @stefan-mees, @stevebuckingham, @susodapop, @taminif, @thewarpaint, @tsuyoshizawa, @uncletimmy3, @wengkham.
### Upgrading
Typically, if you are running your own instance of Redash and wish to upgrade, you would simply modify the Docker tag in your `docker-compose.yml` file. Since RQ has replaced Celery in this version, there are a couple extra modifications that need to be done in your `docker-compose.yml`:
1. Under `services/scheduler/environment`, omit `QUEUES` and `WORKERS_COUNT` (and omit `environment` altogether if it is empty).
2. Under `services`, add a new service for general RQ jobs:
```yaml
worker:
<<: *redash-service
command: worker
environment:
QUEUES: "periodic emails default"
WORKERS_COUNT: 1
```
Following that, force a recreation of your containers with `docker-compose up --force-recreate --build` and you should be good to go.
### UX
- Redesigned Query Results page:
- Completely new layout is easier to read for non-technical Redash users.
- Empty query results are clearly displayed. User is now prompted to edit or execute the query.
- Mobile Experience Improvements:
- UI element spacing has been redesigned for clarity
- Admin pages now honor max-width. Tables scroll independent of the top menu.
- Large legends no longer shrink the visualization on small screens.
- Fix: it was sometimes impossible to scroll pages with dashboards because the visualizations captured every touch event.
- Fix: Visualizations on small screens would not always show horizontal scroll bars.
- Dashboards can now be un-archived using the API.
- Dashboard UI performance was improved.
- List pages were changed to show a user's name instead of avatar.
- Search-enabled tables now show a prompt for which columns will be searched.
- In the visualization editor, the settings pane now scrolls independent of the visualization preview.
- Tokens in the schema viewer now sort alphabetically.
- Links to settings panes that require Admin privileges are now hidden from non-Admins.
- The Admin page now remembers which tab you were viewing after a page reload.
### Visualizations
- Feature: Allow bubble size control with either coefficient or sizemode.
- Feature: Table visualization now treats Unix timestamps in query results as timestamps.
- Feature: It's now possible to provide a description to each Table column, appearing in UI as a tooltip.
- Feature: Added tooltip and popover templating to the map with markers visualization.
- Feature: Added an organization setting to hide the Plotly mode bar on all visualizations.
- Feature: Cohort visualization now has appearance settings.
- Feature: Add option to explicitly set Chart legend position.
- Change: Deprecated visualizations are now hidden.
- Change: Table settings editor now extends vertically instead of horizontally.
- Change: The maximum table pagination is now 500.
- Change: Pie chart labels maintain contrast against lighter slices.
- Fix: Chart series switched places when picking Y axis.
- Fix: Third column was not selectable for Bubble and Heatmap charts.
- Fix: On the counter visualizations, the “count rows” option showed an empty string instead of 0.
- Fix: Table visualization with column named "children" rendered +/- buttons.
- Fix: Sankey visualization now correctly occupies all available area even with fewer stages.
- Fix: Pie chart ignores series labels.
### Data Sources
- New Data Sources: Amazon Cloudwatch, Amazon CloudWatch Logs Insights, Azure Kusto, Exasol.
- Athena:
- Added the option to specify a base cost in settings, displaying a price for each query when executed.
- BigQuery:
- Fix: large jobs continued running after the user clicked “Cancel” query execution.
- Cassandra:
- Updated driver to 3.21.0 which dramatically reduces Docker build times.
- SSL options are now available.
- Clickhouse:
- You can now choose whether to verify the SSL certificate.
- Databricks:
- Databricks now use an ODBC-based connector.
- Fix: Date column was coerced to DateTime in the front-end.
- Druid:
- Added username and password authentication option.
- Microsoft SQL Server
- Added support for ODBC connections via pyodbc. There are now two MSSQL data source types. One using TDS. The other is using ODBC.
- MongoDB:
- Added support for running queries on secondary in replicaset mode.
- Fix: Connection test always succeeded.
- Oracle:
- Fix: Connection would fail if username or password contained special characters.
- Fix: Comparisons would fail if scale was None.
- RDS:
- Updated rds-combined-ca-bundle.pem to the latest CA.
- Redshift:
- Added the ability to use IAM Roles and Users.
- Fix: Redshift was unable to have its schema refreshed.
- Rockset:
- Fix: Allow Redash to load collections in all workspaces.
- Snowflake:
- You can now refresh the snowflake schema without waking the cluster.
- Added support for all of Snowflakes datetime types. Otherwise certain timestamps would only appear as strings in the front-end.
- TreasureData:
- Fix: API calls would fail when setting a non-default region.
### Alerts
- Feature: Added ability to mute alerts without deleting them.
- Fix: numerical comparisons failed if value from query was a string.
### Parameters
- Added Last x Days options for date range parameters.
- Fix: Parameters added in empty queries were always added as text parameters
### Bug Fixes
- Fix: Alembic migration schema was preventing v4 users from upgrading. In v5 we started encrypting data source credentials in the database.
- Fix: System admin dashboard would not show correct database size if non-default name was used.
- Fix: refresh_queries job would break if any query had a bad schedule object.
- Fix: Orgs with LDAP enabled couldnt disable password login.
- Fix: SSL mode was sometimes sent as an empty string to the database instead of omitted entirely.
- Fix: When creating new Map visualization with clustering disabled, map would crash on save.
- Fix: It was possible on the New Query page to click “Save” multiple times, causing multiple new query records to be created.
- Fix: Visualization render errors on a dashboard would crash the entire page.
- Fix: A scheduled execution failure would modify the querys “updated_at” timestamp.
- Fix: Parameter UI would wrap awkwardly during some drag operations.
- Fix: In dashboard edit mode, users couldnt modify widgets.
- Fix: Frontend error when parsing a NaN float.
### Other
- Added TSV as a download format (in addition to CSV and Excel).
- Added maildev settings (helps with automated settings).
- Refine permissions usage in Redash to allow for guest users
- The query results API now explicitly handles 404 errors.
- Forked queries now retain the tags of the original query.
- We now allow setting custom Sentry environments.
- Started using Black linter for our Python source code
- Added CLI command to re-encrypt data source details with new secret key.
- Favorites list is now loaded on menu click instead of on page load.
- Administrators can now allow connections to private IP addresses.
## v8.0.0 - 2019-10-27 ## v8.0.0 - 2019-10-27
There were no changes in this release since `v8.0.0-beta.2`. This is just to mark a stable release. There were no changes in this release since `v8.0.0-beta.2`. This is just to mark a stable release.
@@ -8,24 +152,23 @@ There were no changes in this release since `v8.0.0-beta.2`. This is just to mar
This is an update to the previous beta release, which includes: This is an update to the previous beta release, which includes:
* Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details). - Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details).
* Visualizations: - Visualizations:
- Allow the user to decide how to handle null values in charts. - Allow the user to decide how to handle null values in charts.
* Upgrade Sentry-SDK to latest version. - Upgrade Sentry-SDK to latest version.
* Make horizontal table scroll visible in dashboard widgets without scrolling. - Make horizontal table scroll visible in dashboard widgets without scrolling.
* Data Sources: - Data Sources:
* Add support for Azure Data Explorer (Kusto). - Add support for Azure Data Explorer (Kusto).
* MySQL: fix connections without SSL configuration failing. - MySQL: fix connections without SSL configuration failing.
* Amazon Redshift: option to set query group for adhoc/scheduled queries. - Amazon Redshift: option to set query group for adhoc/scheduled queries.
* Hive: make error message more friendly. - Hive: make error message more friendly.
* Qubole: add support to run Quantum queries. - Qubole: add support to run Quantum queries.
* Display data source icon in query editor. - Display data source icon in query editor.
* Fix: allow users with view only acces to use the queries in Query Results - Fix: allow users with view only acces to use the queries in Query Results
* Dashboard: when updating parameters refersh only widgets that use those parameters. - Dashboard: when updating parameters refersh only widgets that use those parameters.
This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz. This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz.
## v8.0.0-beta - 2019-08-18 ## v8.0.0-beta - 2019-08-18
After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements. After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements.

View File

@@ -1,19 +1,35 @@
FROM node:12 as frontend-builder FROM node:12 as frontend-builder
# Controls whether to build the frontend assets
ARG skip_frontend_build
ENV CYPRESS_INSTALL_BINARY=0
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
RUN useradd -m -d /frontend redash
USER redash
WORKDIR /frontend WORKDIR /frontend
COPY package.json package-lock.json /frontend/ COPY --chown=redash package.json package-lock.json /frontend/
RUN npm install COPY --chown=redash viz-lib /frontend/viz-lib
COPY client /frontend/client # Controls whether to instrument code for coverage information
COPY webpack.config.js /frontend/ ARG code_coverage
RUN npm run build ENV BABEL_ENV=${code_coverage:+test}
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
COPY --chown=redash client /frontend/client
COPY --chown=redash webpack.config.js /frontend/
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
FROM python:3.7-slim FROM python:3.7-slim
EXPOSE 5000 EXPOSE 5000
# Controls whether to install extra dependencies needed for all data sources. # Controls whether to install extra dependencies needed for all data sources.
ARG skip_ds_deps ARG skip_ds_deps
# Controls whether to install dev dependencies.
ARG skip_dev_deps
RUN useradd --create-home redash RUN useradd --create-home redash
@@ -30,22 +46,43 @@ RUN apt-get update && \
wget \ wget \
# Postgres client # Postgres client
libpq-dev \ libpq-dev \
# ODBC support:
g++ unixodbc-dev \
# for SAML # for SAML
xmlsec1 \ xmlsec1 \
# Additional packages required for data sources: # Additional packages required for data sources:
libssl-dev \ libssl-dev \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
freetds-dev \ freetds-dev \
libsasl2-dev && \ libsasl2-dev \
unzip \
libsasl2-modules-gssapi-mit && \
# MSSQL ODBC Driver:
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - && \
curl https://packages.microsoft.com/config/debian/10/prod.list > /etc/apt/sources.list.d/mssql-release.list && \
apt-get update && \
ACCEPT_EULA=Y apt-get install -y msodbcsql17 && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
ARG databricks_odbc_driver_url=https://databricks.com/wp-content/uploads/2.6.10.1010-2/SimbaSparkODBC-2.6.10.1010-2-Debian-64bit.zip
ADD $databricks_odbc_driver_url /tmp/simba_odbc.zip
RUN unzip /tmp/simba_odbc.zip -d /tmp/ \
&& dpkg -i /tmp/SimbaSparkODBC-*/*.deb \
&& echo "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini \
&& rm /tmp/simba_odbc.zip \
&& rm -rf /tmp/SimbaSparkODBC*
WORKDIR /app WORKDIR /app
# Disalbe PIP Cache and Version Check
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV PIP_NO_CACHE_DIR=1
# We first copy only the requirements file, to avoid rebuilding on every file # We first copy only the requirements file, to avoid rebuilding on every file
# change. # change.
COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./ COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./
RUN pip install -r requirements.txt -r requirements_dev.txt RUN if [ "x$skip_dev_deps" = "x" ] ; then pip install -r requirements.txt -r requirements_dev.txt; else pip install -r requirements.txt; fi
RUN if [ "x$skip_ds_deps" = "x" ] ; then pip install -r requirements_all_ds.txt ; else echo "Skipping pip install -r requirements_all_ds.txt" ; fi RUN if [ "x$skip_ds_deps" = "x" ] ; then pip install -r requirements_all_ds.txt ; else echo "Skipping pip install -r requirements_all_ds.txt" ; fi
COPY . /app COPY . /app

View File

@@ -35,7 +35,7 @@ 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: bundle frontend-unit-tests: bundle
npm install CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 npm ci
npm run bundle npm run bundle
npm test npm test

View File

@@ -6,28 +6,77 @@
[![Datree](https://s3.amazonaws.com/catalog.static.datree.io/datree-badge-20px.svg)](https://datree.io/?src=badge) [![Datree](https://s3.amazonaws.com/catalog.static.datree.io/datree-badge-20px.svg)](https://datree.io/?src=badge)
[![Build Status](https://circleci.com/gh/getredash/redash.png?style=shield&circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040)](https://circleci.com/gh/getredash/redash/tree/master) [![Build Status](https://circleci.com/gh/getredash/redash.png?style=shield&circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040)](https://circleci.com/gh/getredash/redash/tree/master)
**_Redash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns. 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.
Prior to **_Redash_**, we tried to use traditional BI suites and discovered a set of bloated, technically challenged and slow tools/flows. What we were looking for was a more hacker'ish way to look at data, so we built one. Redash features:
**_Redash_** was built to allow fast and easy access to billions of records, that we process and collect using Amazon Redshift ("petabyte scale data warehouse" that "speaks" PostgreSQL). 1. **Browser-based**: Everything in your browser, with a shareable URL.
Today **_Redash_** has support for querying multiple databases, including: Redshift, Google BigQuery, PostgreSQL, MySQL, Graphite, Presto, Google Spreadsheets, Cloudera Impala, Hive and custom scripts. 2. **Ease-of-use**: Become immediately productive with data without the need to master complex software.
3. **Query editor**: Quickly compose SQL and NoSQL queries with a schema browser and auto-complete.
**_Redash_** consists of two parts: 4. **Visualization and dashboards**: Create [beautiful visualizations](https://redash.io/help/user-guide/visualizations/visualization-types) with drag and drop, and combine them into a single dashboard.
5. **Sharing**: Collaborate easily by sharing visualizations and their associated queries, enabling peer review of reports and queries.
1. **Query Editor**: think of [JS Fiddle](https://jsfiddle.net) for SQL queries. It's your way to share data in the organization in an open way, by sharing both the dataset and the query that generated it. This way everyone can peer review not only the resulting dataset but also the process that generated it. Also it's possible to fork it and generate new datasets and reach new insights. 6. **Schedule refreshes**: Automatically update your charts and dashboards at regular intervals you define.
2. **Visualizations and Dashboards**: once you have a dataset, you can create different visualizations out of it, and then combine several visualizations into a single dashboard. Currently Redash supports charts, pivot table, cohorts and [more](https://redash.io/help/user-guide/visualizations/visualization-types). 7. **Alerts**: Define conditions and be alerted instantly when your data changes.
8. **REST API**: Everything that can be done in the UI is also available through REST API.
9. **Broad support for data sources**: Extensible data source API with native support for a long list of common databases and platforms.
<img src="https://raw.githubusercontent.com/getredash/website/8e820cd02c73a8ddf4f946a9d293c54fd3fb08b9/website/_assets/images/redash-anim.gif" width="80%"/> <img src="https://raw.githubusercontent.com/getredash/website/8e820cd02c73a8ddf4f946a9d293c54fd3fb08b9/website/_assets/images/redash-anim.gif" width="80%"/>
## Getting Started ## Getting Started
* [Setting up Redash instance](https://redash.io/help/open-source/setup) (includes links to ready made AWS/GCE images). * [Setting up Redash instance](https://redash.io/help/open-source/setup) (includes links to ready-made AWS/GCE images).
* [Documentation](https://redash.io/help/). * [Documentation](https://redash.io/help/).
## Supported Data Sources ## Supported Data Sources
Redash supports more than 35 [data sources](https://redash.io/help/data-sources/supported-data-sources). Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help/data-sources/supported-data-sources). It can also be extended to support more. Below is a list of built-in sources:
- Amazon Athena
- Amazon DynamoDB
- Amazon Redshift
- Axibase Time Series Database
- Cassandra
- ClickHouse
- CockroachDB
- CSV
- Databricks (Apache Spark)
- DB2 by IBM
- Druid
- Elasticsearch
- Google Analytics
- Google BigQuery
- Google Spreadsheets
- Graphite
- Greenplum
- Hive
- Impala
- InfluxDB
- JIRA
- JSON
- Apache Kylin
- OmniSciDB (Formerly MapD)
- MemSQL
- Microsoft Azure Data Warehouse / Synapse
- Microsoft Azure SQL Database
- Microsoft SQL Server
- MongoDB
- MySQL
- Oracle
- PostgreSQL
- Presto
- Prometheus
- Python
- Qubole
- Rockset
- Salesforce
- ScyllaDB
- Shell Scripts
- Snowflake
- SQLite
- TreasureData
- Vertica
- Yandex AppMetrrica
- Yandex Metrica
## Getting Help ## Getting Help
@@ -37,7 +86,7 @@ Redash supports more than 35 [data sources](https://redash.io/help/data-sources/
## Reporting Bugs and Contributing Code ## Reporting Bugs and Contributing Code
* Want to report a bug or request a feature? Please open [an issue](https://github.com/getredash/redash/issues/new). * Want to report a bug or request a feature? Please open [an issue](https://github.com/getredash/redash/issues/new).
* Want to help us build **_Redash_**? Fork the project, edit in a [dev environment](https://redash.io/help-onpremise/dev/guide.html), and make a pull request. We need all the help we can get! * Want to help us build **_Redash_**? Fork the project, edit in a [dev environment](https://redash.io/help-onpremise/dev/guide.html) and make a pull request. We need all the help we can get!
## Security ## Security

View File

@@ -6,8 +6,8 @@ from pathlib import Path
from shutil import copy from shutil import copy
from collections import OrderedDict as odict from collections import OrderedDict as odict
from importlib_metadata import entry_points import importlib_metadata
from importlib_resources import contents, is_resource, path import importlib_resources
# Name of the subdirectory # Name of the subdirectory
BUNDLE_DIRECTORY = "bundle" BUNDLE_DIRECTORY = "bundle"
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
# Make a directory for extensions and set it as an environment variable # Make a directory for extensions and set it as an environment variable
# to be picked up by webpack. # to be picked up by webpack.
extensions_relative_path = Path('client', 'app', 'extensions') extensions_relative_path = Path("client", "app", "extensions")
extensions_directory = Path(__file__).parent.parent / extensions_relative_path extensions_directory = Path(__file__).parent.parent / extensions_relative_path
if not extensions_directory.exists(): if not extensions_directory.exists():
@@ -25,18 +25,6 @@ if not extensions_directory.exists():
os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path) os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path)
def resource_isdir(module, resource):
"""Whether a given resource is a directory in the given module
https://importlib-resources.readthedocs.io/en/latest/migration.html#pkg-resources-resource-isdir
"""
try:
return resource in contents(module) and not is_resource(module, resource)
except (ImportError, TypeError):
# module isn't a package, so can't have a subdirectory/-package
return False
def entry_point_module(entry_point): def entry_point_module(entry_point):
"""Returns the dotted module path for the given entry point""" """Returns the dotted module path for the given entry point"""
return entry_point.pattern.match(entry_point.value).group("module") return entry_point.pattern.match(entry_point.value).group("module")
@@ -77,26 +65,36 @@ def load_bundles():
""" """
bundles = odict() bundles = odict()
for entry_point in entry_points().get("redash.bundles", []): for entry_point in importlib_metadata.entry_points().get("redash.bundles", []):
logger.info('Loading Redash bundle "%s".', entry_point.name) logger.info('Loading Redash bundle "%s".', entry_point.name)
module = entry_point_module(entry_point) module = entry_point_module(entry_point)
# Try to get a list of bundle files # Try to get a list of bundle files
if not resource_isdir(module, BUNDLE_DIRECTORY): try:
bundle_dir = importlib_resources.files(module).joinpath(BUNDLE_DIRECTORY)
except (ImportError, TypeError):
# Module isn't a package, so can't have a subdirectory/-package
logger.error( logger.error(
'Redash bundle directory "%s" could not be found.', entry_point.name 'Redash bundle module "%s" could not be imported: "%s"',
entry_point.name,
module,
)
continue
if not bundle_dir.is_dir():
logger.error(
'Redash bundle directory "%s" could not be found or is not a directory: "%s"',
entry_point.name,
bundle_dir,
) )
continue continue
with path(module, BUNDLE_DIRECTORY) as bundle_dir:
bundles[entry_point.name] = list(bundle_dir.rglob("*")) bundles[entry_point.name] = list(bundle_dir.rglob("*"))
return bundles return bundles
bundles = load_bundles().items() bundles = load_bundles().items()
if bundles: if bundles:
print('Number of extension bundles found: {}'.format(len(bundles))) print("Number of extension bundles found: {}".format(len(bundles)))
else: else:
print('No extension bundles found.') print("No extension bundles found.")
for bundle_name, paths in bundles: for bundle_name, paths in bundles:
# Shortcut in case not paths were found for the bundle # Shortcut in case not paths were found for the bundle

View File

@@ -19,7 +19,7 @@ worker() {
export WORKERS_COUNT=${WORKERS_COUNT:-2} export WORKERS_COUNT=${WORKERS_COUNT:-2}
export QUEUES=${QUEUES:-} export QUEUES=${QUEUES:-}
supervisord -c worker.conf exec supervisord -c worker.conf
} }
dev_worker() { dev_worker() {
@@ -39,10 +39,6 @@ create_db() {
exec /app/manage.py database create_tables exec /app/manage.py database create_tables
} }
rq_healthcheck() {
exec /app/manage.py rq healthcheck
}
help() { help() {
echo "Redash Docker." echo "Redash Docker."
echo "" echo ""
@@ -54,7 +50,6 @@ help() {
echo "dev_worker -- start a single RQ worker with code reloading" echo "dev_worker -- start a single RQ worker with code reloading"
echo "scheduler -- start an rq-scheduler instance" echo "scheduler -- start an rq-scheduler instance"
echo "dev_scheduler -- start an rq-scheduler instance with code reloading" echo "dev_scheduler -- start an rq-scheduler instance with code reloading"
echo "rq_healthcheck -- runs a RQ healthcheck that verifies that all local workers are active. Useful for Docker's HEALTHCHECK mechanism."
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"
@@ -96,9 +91,9 @@ case "$1" in
shift shift
dev_worker dev_worker
;; ;;
rq_healthcheck) celery_healthcheck)
shift shift
rq_healthcheck echo "DEPRECATED: Celery has been replaced with RQ and now performs healthchecks autonomously as part of the 'worker' entrypoint."
;; ;;
dev_server) dev_server)
export FLASK_DEBUG=1 export FLASK_DEBUG=1
@@ -131,4 +126,3 @@ case "$1" in
exec "$@" exec "$@"
;; ;;
esac esac

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env bash
# Heroku pre_compile script
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
pushd $DIR/..
# heroku requires cffi to be in requirements.txt in order for libffi to be installed.
# https://github.com/heroku/heroku-buildpack-python/blob/master/bin/steps/cryptography
# to avoid making it a requirement for other build systems, we'll inject it now
# into the requirements.txt file
# Remove Heroku unsupported Python packages:
grep -v -E "^(pymssql|thrift|sasl|pyhive)" requirements_all_ds.txt >> requirements.txt
# make the heroku Procfile the active one
cp Procfile.heroku Procfile
popd

View File

@@ -1,19 +1,29 @@
{ {
"presets": [ "presets": [
["@babel/preset-env", { [
"exclude": [ "@babel/preset-env",
"@babel/plugin-transform-async-to-generator", {
"@babel/plugin-transform-arrow-functions" "exclude": ["@babel/plugin-transform-async-to-generator", "@babel/plugin-transform-arrow-functions"],
], "corejs": "2",
"useBuiltIns": "usage" "useBuiltIns": "usage"
}], }
"@babel/preset-react" ],
"@babel/preset-react",
"@babel/preset-typescript"
], ],
"plugins": [ "plugins": [
"@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-object-assign", "@babel/plugin-transform-object-assign",
["babel-plugin-transform-builtin-extend", { [
"babel-plugin-transform-builtin-extend",
{
"globals": ["Error"] "globals": ["Error"]
}] }
] ]
],
"env": {
"test": {
"plugins": ["istanbul"]
}
}
} }

View File

@@ -1,17 +1,57 @@
module.exports = { module.exports = {
root: true, root: true,
extends: ["react-app", "plugin:compat/recommended", "prettier"], parser: "@typescript-eslint/parser",
plugins: ["jest", "compat", "no-only-tests"], extends: [
"react-app",
"plugin:compat/recommended",
"prettier",
// Remove any typescript-eslint rules that would conflict with prettier
"prettier/@typescript-eslint",
],
plugins: ["jest", "compat", "no-only-tests", "@typescript-eslint"],
settings: { settings: {
"import/resolver": "webpack" "import/resolver": "webpack",
}, },
env: { env: {
browser: true, browser: true,
node: true node: true,
}, },
rules: { rules: {
// allow debugger during development // allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0, "no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
"jsx-a11y/anchor-is-valid": "off", "jsx-a11y/anchor-is-valid": "off",
} "no-restricted-imports": [
"error",
{
paths: [
{
name: "antd",
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
},
{
name: "antd/lib",
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
},
],
},
],
},
overrides: [
{
// Only run typescript-eslint on TS files
files: ["*.ts", "*.tsx", ".*.ts", ".*.tsx"],
extends: ["plugin:@typescript-eslint/recommended"],
rules: {
// Do not require functions (especially react components) to have explicit returns
"@typescript-eslint/explicit-function-return-type": "off",
// Do not require to type every import from a JS file to speed up development
"@typescript-eslint/no-explicit-any": "off",
// Do not complain about useless contructors in declaration files
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": "error",
// Many API fields and generated types use camelcase
"@typescript-eslint/camelcase": "off",
},
},
],
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,13 @@
<svg width="274" height="199" viewBox="0 0 274 199" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.5" d="M57.9111 49.2668L202.769 30" stroke="#F2F2F2" stroke-width="59" stroke-linecap="round"/>
<path opacity="0.5" d="M39.2842 92.7371L244.24 64.886" stroke="#F2F2F2" stroke-width="59" stroke-linecap="round"/>
<path opacity="0.5" d="M30 136.299L232.813 107.734" stroke="#F2F2F2" stroke-width="59" stroke-linecap="round"/>
<path opacity="0.5" d="M86.4541 169.149L234.166 150.596" stroke="#F2F2F2" stroke-width="59" stroke-linecap="round"/>
<path d="M167.829 69.1349H96.458L117.605 51.9531H183.028L167.829 69.1349Z" fill="#C0D5FF"/>
<path d="M171.133 70.4566H92.4933V85.6559V143.149H171.133V70.4566Z" fill="#E8F4FF"/>
<path d="M190.298 48.6489L171.133 70.4566L186.993 94.9076L192.28 89.9514L206.818 73.7608L190.298 48.6489Z" fill="#E8F4FF"/>
<path d="M171.133 70.4566V143.149L192.28 118.037V89.9514L186.993 94.9076L171.133 70.4566Z" fill="#E8F4FF"/>
<path d="M92.4933 70.4566L81.9199 89.9514L92.4933 85.6559V70.4566Z" fill="#E8F4FF"/>
<path d="M92.4933 70.4566H171.133M92.4933 70.4566L118.927 48.6489H190.298M92.4933 70.4566L81.9199 89.9514L92.4933 85.6559M92.4933 70.4566V85.6559M171.133 70.4566V143.149M171.133 70.4566L190.298 48.6489M171.133 70.4566L186.993 94.9076L192.28 89.9514M171.133 143.149H92.4933V85.6559M171.133 143.149L192.28 118.037V89.9514M190.298 48.6489L206.818 73.7608L192.28 89.9514" stroke="black" stroke-width="3" stroke-linejoin="round"/>
<path d="M117.605 89.6208H147.343" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,42 +1,43 @@
@import '~antd/lib/style/core/iconfont'; @import "~antd/lib/style/core/iconfont";
@import '~antd/lib/style/core/motion'; @import "~antd/lib/style/core/motion";
@import '~antd/lib/alert/style/index'; @import "~antd/lib/alert/style/index";
@import '~antd/lib/input/style/index'; @import "~antd/lib/input/style/index";
@import '~antd/lib/input-number/style/index'; @import "~antd/lib/input-number/style/index";
@import '~antd/lib/date-picker/style/index'; @import "~antd/lib/date-picker/style/index";
@import '~antd/lib/modal/style/index'; @import "~antd/lib/modal/style/index";
@import '~antd/lib/tooltip/style/index'; @import "~antd/lib/tooltip/style/index";
@import '~antd/lib/select/style/index'; @import "~antd/lib/select/style/index";
@import '~antd/lib/checkbox/style/index'; @import "~antd/lib/checkbox/style/index";
@import '~antd/lib/upload/style/index'; @import "~antd/lib/upload/style/index";
@import '~antd/lib/form/style/index'; @import "~antd/lib/form/style/index";
@import '~antd/lib/button/style/index'; @import "~antd/lib/button/style/index";
@import '~antd/lib/radio/style/index'; @import "~antd/lib/radio/style/index";
@import '~antd/lib/time-picker/style/index'; @import "~antd/lib/time-picker/style/index";
@import '~antd/lib/pagination/style/index'; @import "~antd/lib/pagination/style/index";
@import '~antd/lib/table/style/index'; @import "~antd/lib/table/style/index";
@import '~antd/lib/popover/style/index'; @import "~antd/lib/popover/style/index";
@import '~antd/lib/icon/style/index'; @import "~antd/lib/tag/style/index";
@import '~antd/lib/tag/style/index'; @import "~antd/lib/grid/style/index";
@import '~antd/lib/grid/style/index'; @import "~antd/lib/switch/style/index";
@import '~antd/lib/switch/style/index'; @import "~antd/lib/empty/style/index";
@import '~antd/lib/empty/style/index'; @import "~antd/lib/drawer/style/index";
@import '~antd/lib/drawer/style/index'; @import "~antd/lib/card/style/index";
@import '~antd/lib/card/style/index'; @import "~antd/lib/steps/style/index";
@import '~antd/lib/steps/style/index'; @import "~antd/lib/divider/style/index";
@import '~antd/lib/divider/style/index'; @import "~antd/lib/dropdown/style/index";
@import '~antd/lib/dropdown/style/index'; @import "~antd/lib/menu/style/index";
@import '~antd/lib/menu/style/index'; @import "~antd/lib/list/style/index";
@import '~antd/lib/list/style/index';
@import "~antd/lib/badge/style/index"; @import "~antd/lib/badge/style/index";
@import "~antd/lib/card/style/index"; @import "~antd/lib/card/style/index";
@import "~antd/lib/spin/style/index"; @import "~antd/lib/spin/style/index";
@import "~antd/lib/skeleton/style/index";
@import "~antd/lib/tabs/style/index"; @import "~antd/lib/tabs/style/index";
@import "~antd/lib/notification/style/index"; @import "~antd/lib/notification/style/index";
@import "~antd/lib/collapse/style/index"; @import "~antd/lib/collapse/style/index";
@import "~antd/lib/progress/style/index"; @import "~antd/lib/progress/style/index";
@import "~antd/lib/typography/style/index"; @import "~antd/lib/typography/style/index";
@import 'inc/ant-variables'; @import "~antd/lib/descriptions/style/index";
@import "inc/ant-variables";
// Increase z-indexes to avoid conflicts with some other libraries (e.g. Plotly) // Increase z-indexes to avoid conflicts with some other libraries (e.g. Plotly)
@zindex-modal: 2000; @zindex-modal: 2000;
@@ -237,11 +238,11 @@
&-item { &-item {
// custom rule // custom rule
&.selected { &.selected {
background-color: #F6F8F9; background-color: #f6f8f9;
} }
&.disabled { &.disabled {
background-color: fade(#F6F8F9, 40%); background-color: fade(#f6f8f9, 40%);
& > * { & > * {
opacity: 0.4; opacity: 0.4;
@@ -394,9 +395,20 @@
} }
// overrides for checkbox // overrides for checkbox
@checkbox-prefix-cls: ~'@{ant-prefix}-checkbox'; @checkbox-prefix-cls: ~"@{ant-prefix}-checkbox";
.@{checkbox-prefix-cls}-wrapper + span, .@{checkbox-prefix-cls}-wrapper + span,
.@{checkbox-prefix-cls} + span { .@{checkbox-prefix-cls} + span {
padding-right: 0; padding-right: 0;
} }
// make sure Multiple select has room for icons
.@{select-prefix-cls}-multiple {
&.@{select-prefix-cls}-show-arrow,
&.@{select-prefix-cls}-show-search,
&.@{select-prefix-cls}-loading {
.@{select-prefix-cls}-selector {
padding-right: 30px;
}
}
}

View File

@@ -23,6 +23,10 @@
padding: 5px 8px; padding: 5px 8px;
} }
.ant-form-item-explain {
margin-top: 10px;
}
.alert-last-triggered { .alert-last-triggered {
color: @headings-color; color: @headings-color;
} }
@@ -43,11 +47,3 @@
margin-right: 0 !important; margin-right: 0 !important;
} }
} }
.alert-actions {
flex-grow: 1;
display: flex;
justify-content: flex-end;
align-items: center;
margin-right: -15px;
}

View File

@@ -1,8 +1,8 @@
/* -------------------------------------------------------- /* --------------------------------------------------------
Colors Colors
-----------------------------------------------------------*/ -----------------------------------------------------------*/
@lightblue: #03A9F4; @lightblue: #03a9f4;
@primary-color: #2196F3; @primary-color: #2196f3;
@redash-gray: rgba(102, 136, 153, 1); @redash-gray: rgba(102, 136, 153, 1);
@redash-orange: rgba(255, 120, 100, 1); @redash-orange: rgba(255, 120, 100, 1);
@@ -12,25 +12,23 @@
/* -------------------------------------------------------- /* --------------------------------------------------------
Font Font
-----------------------------------------------------------*/ -----------------------------------------------------------*/
@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; @redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue",
sans-serif;
@font-family-no-number: @redash-font; @font-family-no-number: @redash-font;
@font-family: @redash-font; @font-family: @redash-font;
@code-family: @redash-font; @code-family: @redash-font;
@font-size-base: 13px; @font-size-base: 13px;
/* -------------------------------------------------------- /* --------------------------------------------------------
Borders Borders
-----------------------------------------------------------*/ -----------------------------------------------------------*/
@border-color-split: #f0f0f0; @border-color-split: #f0f0f0;
/* -------------------------------------------------------- /* --------------------------------------------------------
Typograpgy Typograpgy
-----------------------------------------------------------*/ -----------------------------------------------------------*/
@text-color: #595959; @text-color: #595959;
/* -------------------------------------------------------- /* --------------------------------------------------------
Form Form
-----------------------------------------------------------*/ -----------------------------------------------------------*/
@@ -38,15 +36,7 @@
@input-color: #595959; @input-color: #595959;
@input-color-placeholder: #b4b4b4; @input-color-placeholder: #b4b4b4;
@border-radius-base: 2px; @border-radius-base: 2px;
@border-color-base: #E8E8E8; @border-color-base: #e8e8e8;
/* --------------------------------------------------------
Button
-----------------------------------------------------------*/
@btn-danger-bg: fade(@redash-gray, 10%);
@btn-danger-border: fade(@redash-gray, 15%);
/* -------------------------------------------------------- /* --------------------------------------------------------
Pagination Pagination
@@ -56,14 +46,13 @@
@pagination-font-weight-active: normal; @pagination-font-weight-active: normal;
@pagination-bg: fade(@redash-gray, 15%); @pagination-bg: fade(@redash-gray, 15%);
@pagination-color: #7E7E7E; @pagination-color: #7e7e7e;
@pagination-active-bg: @lightblue; @pagination-active-bg: @lightblue;
@pagination-active-color: #FFF; @pagination-active-color: #fff;
@pagination-disabled-bg: fade(@redash-gray, 15%); @pagination-disabled-bg: fade(@redash-gray, 15%);
@pagination-hover-color: #333; @pagination-hover-color: #333;
@pagination-hover-bg: fade(@redash-gray, 25%); @pagination-hover-bg: fade(@redash-gray, 25%);
/* -------------------------------------------------------- /* --------------------------------------------------------
Table Table
-----------------------------------------------------------*/ -----------------------------------------------------------*/

View File

@@ -32,17 +32,6 @@ body {
#application-root { #application-root {
padding-bottom: 15px; padding-bottom: 15px;
} }
&.headless {
#application-root {
padding-top: 10px;
padding-bottom: 0;
}
.app-header-wrapper {
display: none;
}
}
} }
#application-root { #application-root {
@@ -89,46 +78,16 @@ strong {
} }
} }
// Fixed width layout for specific pages
@media (min-width: 768px) {
.settings-screen, .settings-screen,
.home-page, .home-page,
.page-dashboard-list, .page-dashboard-list,
.page-queries-list, .page-queries-list,
.page-alerts-list, .page-alerts-list,
.alert-page, .alert-page,
.fixed-container { .admin-page-layout {
.container { .container {
width: 750px; width: 100%;
} max-width: none;
}
}
@media (min-width: 992px) {
.settings-screen,
.home-page,
.page-dashboard-list,
.page-queries-list,
.page-alerts-list,
.alert-page,
.fixed-container {
.container {
width: 970px;
}
}
}
@media (min-width: 1200px) {
.settings-screen,
.home-page,
.page-dashboard-list,
.page-queries-list,
.page-alerts-list,
.alert-page,
.fixed-container {
.container {
width: 1170px;
}
} }
} }
@@ -244,10 +203,6 @@ text.slicetext {
display: flex; display: flex;
align-items: center; align-items: center;
h3 {
margin-right: 5px !important;
}
.label { .label {
margin-top: 3px; margin-top: 3px;
display: inline-block; display: inline-block;
@@ -255,11 +210,10 @@ text.slicetext {
.favorites-control { .favorites-control {
font-size: 19px; font-size: 19px;
margin-right: 5px; margin-right: 10px;
} }
} }
.page-header-wrapper,
.page-header--new { .page-header--new {
h3 { h3 {
margin: 0.2em 0; margin: 0.2em 0;

View File

@@ -6,6 +6,7 @@ div.table-name {
padding: 2px 22px 2px 10px; padding: 2px 22px 2px 10px;
border-radius: @redash-radius; border-radius: @redash-radius;
position: relative; position: relative;
height: 22px;
.copy-to-editor { .copy-to-editor {
display: none; display: none;
@@ -27,13 +28,19 @@ div.table-name {
} }
.schema-browser { .schema-browser {
overflow-y: auto; overflow: hidden;
overflow-x: hidden;
border: none; border: none;
margin-top: 10px; padding-top: 10px;
position: relative; position: relative;
height: 100%; height: 100%;
.schema-loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.collapse.in { .collapse.in {
background: transparent; background: transparent;
} }
@@ -57,6 +64,14 @@ div.table-name {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
height: 18px;
.column-type {
color: fade(@text-color, 80%);
font-size: 10px;
margin-left: 2px;
text-transform: uppercase;
}
.copy-to-editor { .copy-to-editor {
display: none; display: none;

View File

@@ -6,7 +6,7 @@
} }
&:not(.table-striped) > thead > tr > th { &:not(.table-striped) > thead > tr > th {
background-color: #FAFAFA; background-color: #fafafa;
} }
[class*="bg-"] { [class*="bg-"] {
@@ -33,9 +33,8 @@
& > thead > tr, & > thead > tr,
& > tbody > tr, & > tbody > tr,
& > tfoot > tr { & > tfoot > tr {
& > th,
& > th, & > td { & > td {
&:first-child { &:first-child {
padding-left: 30px; padding-left: 30px;
} }
@@ -43,7 +42,6 @@
&:last-child { &:last-child {
padding-right: 30px; padding-right: 30px;
} }
} }
} }
@@ -56,7 +54,8 @@
border: 0; border: 0;
& > tbody > tr { & > tbody > tr {
& > td, & > th { & > td,
& > th {
border-bottom: 0; border-bottom: 0;
border-left: 0; border-left: 0;
@@ -86,10 +85,8 @@
} }
.tile .table { .tile .table {
& > thead:not([class*="bg-"]) > tr > th { & > thead:not([class*="bg-"]) > tr > th {
border-top: 1px solid @table-border-color; border-top: 1px solid @table-border-color;
} }
} }
@@ -98,11 +95,16 @@
} }
.table-data { .table-data {
thead > tr > th {
white-space: nowrap;
}
tbody > tr > td { tbody > tr > td {
padding-top: 5px !important; padding-top: 5px !important;
} }
.btn-favourite, .btn-archive { .btn-favourite,
.btn-archive {
font-size: 15px; font-size: 15px;
} }
} }
@@ -114,9 +116,10 @@
.btn-favourite { .btn-favourite {
color: #d4d4d4; color: #d4d4d4;
transition: all .25s ease-in-out; transition: all 0.25s ease-in-out;
&:hover, &:focus { &:hover,
&:focus {
color: @yellow-darker; color: @yellow-darker;
cursor: pointer; cursor: pointer;
} }
@@ -128,9 +131,10 @@
.btn-archive { .btn-archive {
color: #d4d4d4; color: #d4d4d4;
transition: all .25s ease-in-out; transition: all 0.25s ease-in-out;
&:hover, &:focus { &:hover,
&:focus {
color: @gray-light; color: @gray-light;
} }

View File

@@ -7,7 +7,6 @@
/** Load Vendors Dependencies **/ /** Load Vendors Dependencies **/
@import "~font-awesome/less/font-awesome"; @import "~font-awesome/less/font-awesome";
@import "~material-design-iconic-font/dist/css/material-design-iconic-font.css"; @import "~material-design-iconic-font/dist/css/material-design-iconic-font.css";
@import "~pace-progress/themes/blue/pace-theme-minimal.css";
@import "inc/variables"; @import "inc/variables";
@import "inc/mixins"; @import "inc/mixins";

View File

@@ -4,47 +4,18 @@ body.fixed-layout {
#application-root { #application-root {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
padding-bottom: 0; padding-bottom: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
> div { .application-layout-content > div {
flex-grow: 1;
display: flex; display: flex;
} }
} }
} }
.bottom-controller {
padding: 10px 15px;
background: #fff;
display: flex;
align-items: center;
button,
div,
span {
position: relative;
}
div:last-child {
flex-grow: 1;
text-align: right;
}
&:before {
content: "";
height: 50px;
position: fixed;
bottom: 0;
width: 100%;
pointer-events: none;
left: 0;
}
}
.p-b-60 { .p-b-60 {
padding-bottom: 60px !important; padding-bottom: 60px !important;
} }
@@ -101,9 +72,6 @@ body.fixed-layout {
} }
} }
.embed__vis {
}
.query__vis { .query__vis {
table { table {
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
@@ -122,6 +90,7 @@ body.fixed-layout {
.embed__vis { .embed__vis {
display: flex; display: flex;
flex-flow: column; flex-flow: column;
width: 100%;
} }
.embed-heading { .embed-heading {
@@ -140,6 +109,14 @@ body.fixed-layout {
} }
} }
// Don't let filters take all visualization space on query fixed layout
.query-fixed-layout {
.filters-wrapper {
max-height: 40%;
overflow: auto;
}
}
.page-header--new { .page-header--new {
.query-tags, .query-tags,
.query-tags__mobile { .query-tags__mobile {
@@ -150,25 +127,6 @@ body.fixed-layout {
} }
} }
.page-header--query {
.page-title {
display: block;
margin-left: 15px;
margin-right: 15px;
}
.tags-control a {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
&:hover {
.tags-control a {
opacity: 1;
}
}
}
a.label-tag { a.label-tag {
background: fade(@redash-gray, 15%); background: fade(@redash-gray, 15%);
color: darken(@redash-gray, 15%); color: darken(@redash-gray, 15%);
@@ -179,14 +137,11 @@ a.label-tag {
} }
} }
.schema-browser {
overflow-y: auto;
}
.query-page-wrapper { .query-page-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
position: relative;
} }
.query-fullscreen { .query-fullscreen {
@@ -195,7 +150,6 @@ a.label-tag {
box-shadow: rgba(102, 136, 153, 0.15) 0 4px 9px -3px; box-shadow: rgba(102, 136, 153, 0.15) 0 4px 9px -3px;
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
width: 100vw;
.resizable-component.react-resizable { .resizable-component.react-resizable {
.react-resizable-handle-horizontal { .react-resizable-handle-horizontal {
@@ -275,11 +229,6 @@ a.label-tag {
align-content: space-around; align-content: space-around;
padding: 0; padding: 0;
overflow-x: hidden; overflow-x: hidden;
.pivot-table-visualization-container > table,
.visualization-renderer > .visualization-renderer-wrapper {
overflow: visible;
}
} }
.row { .row {
background: #fff; background: #fff;
@@ -446,12 +395,10 @@ nav .rg-bottom {
.query-tags { .query-tags {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
margin-top: -3px; // padding-top of tags
} }
.query-tags__mobile { .query-tags__mobile {
display: none; display: none;
padding: 0 0 0 23px;
} }
.table--permission { .table--permission {
@@ -479,21 +426,6 @@ nav .rg-bottom {
// Smaller screens // Smaller screens
@media (max-width: 880px) { @media (max-width: 880px) {
.page-header--query {
.page-title {
margin-left: 0;
margin-right: 0;
}
}
.query-tags:not(.query-tags__empty) {
display: none;
}
.query-tags__mobile:not(.query-tags__empty) {
display: block;
}
.btn--showhide, .btn--showhide,
.query-actions-menu .dropdown-toggle { .query-actions-menu .dropdown-toggle {
margin-bottom: 5px; margin-bottom: 5px;
@@ -547,13 +479,6 @@ nav .rg-bottom {
} }
} }
.query-page-wrapper {
.container {
margin-left: 0;
margin-right: 0;
}
}
.datasource-small { .datasource-small {
visibility: visible; visibility: visible;
} }
@@ -569,12 +494,3 @@ nav .rg-bottom {
padding-right: 0; padding-right: 0;
} }
} }
// Responsive fixes
@media (max-width: 767px) {
.query-page-wrapper {
h3 {
font-size: 18px;
}
}
}

View File

@@ -10,6 +10,10 @@
display: inline-block; display: inline-block;
} }
.tag-separator {
margin: 4px 3px 0 0;
}
&.disabled { &.disabled {
opacity: 0.4; opacity: 0.4;
} }

View File

@@ -5,7 +5,7 @@ import "./AceEditorInput.less";
function AceEditorInput(props, ref) { function AceEditorInput(props, ref) {
return ( return (
<div className="ace-editor-input"> <div className="ace-editor-input" data-test={props["data-test"]}>
<AceEditor <AceEditor
ref={ref} ref={ref}
mode="sql" mode="sql"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,176 @@
import { first } from "lodash";
import React, { useState } from "react";
import Button from "antd/lib/button";
import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png";
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
import VersionInfo from "./VersionInfo";
import "./DesktopNavbar.less";
function NavbarSection({ inlineCollapsed, children, ...props }) {
return (
<Menu
selectable={false}
mode={inlineCollapsed ? "inline" : "vertical"}
inlineCollapsed={inlineCollapsed}
theme="dark"
{...props}>
{children}
</Menu>
);
}
export default function DesktopNavbar() {
const [collapsed, setCollapsed] = useState(true);
const firstSettingsTab = first(settingsMenu.getAvailableItems());
const canCreateQuery = currentUser.hasPermission("create_query");
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
const canCreateAlert = currentUser.hasPermission("list_alerts");
return (
<div className="desktop-navbar">
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
<div>
<Link href="./">
<img src={logoUrl} alt="Redash" />
</Link>
</div>
</NavbarSection>
<NavbarSection inlineCollapsed={collapsed}>
{currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards">
<Link href="dashboards">
<DesktopOutlinedIcon />
<span>Dashboards</span>
</Link>
</Menu.Item>
)}
{currentUser.hasPermission("view_query") && (
<Menu.Item key="queries">
<Link href="queries">
<CodeOutlinedIcon />
<span>Queries</span>
</Link>
</Menu.Item>
)}
{currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts">
<Link href="alerts">
<AlertOutlinedIcon />
<span>Alerts</span>
</Link>
</Menu.Item>
)}
</NavbarSection>
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer">
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
<Menu.SubMenu
key="create"
popupClassName="desktop-navbar-submenu"
title={
<React.Fragment>
<span data-test="CreateButton">
<PlusOutlinedIcon />
<span>Create</span>
</span>
</React.Fragment>
}>
{canCreateQuery && (
<Menu.Item key="new-query">
<Link href="queries/new" data-test="CreateQueryMenuItem">
New Query
</Link>
</Menu.Item>
)}
{canCreateDashboard && (
<Menu.Item key="new-dashboard">
<a data-test="CreateDashboardMenuItem" onMouseUp={() => CreateDashboardDialog.showModal()}>
New Dashboard
</a>
</Menu.Item>
)}
{canCreateAlert && (
<Menu.Item key="new-alert">
<Link data-test="CreateAlertMenuItem" href="alerts/new">
New Alert
</Link>
</Menu.Item>
)}
</Menu.SubMenu>
)}
</NavbarSection>
<NavbarSection inlineCollapsed={collapsed}>
<Menu.Item key="help">
<HelpTrigger showTooltip={false} type="HOME">
<QuestionCircleOutlinedIcon />
<span>Help</span>
</HelpTrigger>
</Menu.Item>
{firstSettingsTab && (
<Menu.Item key="settings">
<Link href={firstSettingsTab.path} data-test="SettingsLink">
<SettingOutlinedIcon />
<span>Settings</span>
</Link>
</Menu.Item>
)}
<Menu.Divider />
</NavbarSection>
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu">
<Menu.SubMenu
key="profile"
popupClassName="desktop-navbar-submenu"
title={
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
<span>{currentUser.name}</span>
</span>
}>
<Menu.Item key="profile">
<Link href="users/me">Profile</Link>
</Menu.Item>
{currentUser.hasPermission("super_admin") && (
<Menu.Item key="status">
<Link href="admin/status">System Status</Link>
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item key="logout">
<a data-test="LogOutButton" onClick={() => Auth.logout()}>
Log out
</a>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="version" disabled className="version-info">
<VersionInfo />
</Menu.Item>
</Menu.SubMenu>
</NavbarSection>
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
</Button>
</div>
);
}

View File

@@ -0,0 +1,181 @@
@backgroundColor: #001529;
@dividerColor: rgba(255, 255, 255, 0.5);
@textColor: rgba(255, 255, 255, 0.75);
.desktop-navbar {
background: @backgroundColor;
display: flex;
flex-direction: column;
height: 100%;
&-spacer {
flex: 1 1 auto;
}
&-logo.ant-menu {
padding-top: 20px;
padding-bottom: 20px;
text-align: center;
img {
height: 40px;
transition: all 270ms;
}
&.ant-menu-inline-collapsed {
img {
height: 20px;
}
}
}
.help-trigger {
font: inherit;
}
.ant-menu {
&:not(.ant-menu-inline-collapsed) {
width: 170px;
}
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
display: inline-block;
max-width: 0;
opacity: 0;
}
.ant-menu-item-divider {
background: @dividerColor;
}
.ant-menu-item,
.ant-menu-submenu {
font-weight: 500;
color: @textColor;
&.ant-menu-submenu-open,
&.ant-menu-submenu-active,
&:hover,
&:active {
color: #fff;
}
a,
span,
.anticon {
color: inherit;
}
}
.ant-menu-submenu-arrow {
display: none;
}
}
.ant-btn.desktop-navbar-collapse-button {
background-color: @backgroundColor;
border: 0;
border-radius: 0;
color: @textColor;
&:hover,
&:active {
color: #fff;
}
&:after {
animation: 0s !important;
}
}
.desktop-navbar-profile-menu {
.desktop-navbar-profile-menu-title {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.profile__image_thumb {
margin: 0;
vertical-align: middle;
}
.profile__image_thumb + span {
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 10px;
vertical-align: middle;
display: inline-block;
// styles from Antd
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
&.ant-menu-inline-collapsed {
.ant-menu-submenu-title {
padding-left: 16px !important;
padding-right: 16px !important;
}
.desktop-navbar-profile-menu-title {
.profile__image_thumb + span {
opacity: 0;
max-width: 0;
margin-left: 0;
}
}
}
}
}
.desktop-navbar-submenu {
.ant-menu {
.ant-menu-item-divider {
background: @dividerColor;
}
.ant-menu-item {
font-weight: 500;
color: @textColor;
&:hover,
&:active {
color: #fff;
}
a,
span,
.anticon {
color: inherit;
}
.zmdi,
.fa {
margin-right: 5px;
}
&.version-info {
height: auto;
line-height: normal;
padding-top: 12px;
padding-bottom: 12px;
a {
color: rgba(255, 255, 255, 0.8);
&:hover,
&:active {
color: rgba(255, 255, 255, 1);
}
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
import { first } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png";
import "./MobileNavbar.less";
export default function MobileNavbar({ getPopupContainer }) {
const firstSettingsTab = first(settingsMenu.getAvailableItems());
return (
<div className="mobile-navbar">
<div className="mobile-navbar-logo">
<Link href="./">
<img src={logoUrl} alt="Redash" />
</Link>
</div>
<div>
<Dropdown
overlayStyle={{ minWidth: 200 }}
trigger={["click"]}
getPopupContainer={getPopupContainer} // so the overlay menu stays with the fixed header when page scrolls
overlay={
<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
{currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards">
<Link href="dashboards">Dashboards</Link>
</Menu.Item>
)}
{currentUser.hasPermission("view_query") && (
<Menu.Item key="queries">
<Link href="queries">Queries</Link>
</Menu.Item>
)}
{currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts">
<Link href="alerts">Alerts</Link>
</Menu.Item>
)}
<Menu.Item key="profile">
<Link href="users/me">Edit Profile</Link>
</Menu.Item>
<Menu.Divider />
{firstSettingsTab && (
<Menu.Item key="settings">
<Link href={firstSettingsTab.path}>Settings</Link>
</Menu.Item>
)}
{currentUser.hasPermission("super_admin") && (
<Menu.Item key="status">
<Link href="admin/status">System Status</Link>
</Menu.Item>
)}
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
<Menu.Item key="help">
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<Link href="https://redash.io/help" target="_blank" rel="noopener">
Help
</Link>
</Menu.Item>
<Menu.Item key="logout" onClick={() => Auth.logout()}>
Log out
</Menu.Item>
</Menu>
}>
<Button className="mobile-navbar-toggle-button" ghost>
<MenuOutlinedIcon />
</Button>
</Dropdown>
</div>
</div>
);
}
MobileNavbar.propTypes = {
getPopupContainer: PropTypes.func,
};
MobileNavbar.defaultProps = {
getPopupContainer: null,
};

View File

@@ -0,0 +1,35 @@
@backgroundColor: #001529;
@dividerColor: rgba(255, 255, 255, 0.5);
@textColor: rgba(255, 255, 255, 0.75);
.mobile-navbar {
display: flex;
justify-content: space-between;
align-items: center;
background: @backgroundColor;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
padding: 0 15px;
height: 100%;
&-logo {
img {
height: 40px;
width: 40px;
}
}
.ant-btn.mobile-navbar-toggle-button {
padding: 0 10px;
}
}
.mobile-navbar-menu {
.ant-dropdown-menu-item {
font-weight: 500;
color: @textColor;
}
.ant-dropdown-menu-item-divider {
background: @dividerColor;
}
}

View File

@@ -0,0 +1,24 @@
import React from "react";
import Link from "@/components/Link";
import { clientConfig, currentUser } from "@/services/auth";
import frontendVersion from "@/version.json";
export default function VersionInfo() {
return (
<React.Fragment>
<div>
Version: {clientConfig.version}
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
</div>
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
<div className="m-t-10">
{/* eslint-disable react/jsx-no-target-blank */}
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
Update Available
<i className="fa fa-external-link m-l-5" />
</Link>
</div>
)}
</React.Fragment>
);
}

View File

@@ -0,0 +1,41 @@
import React, { useRef, useCallback } from "react";
import PropTypes from "prop-types";
import DynamicComponent from "@/components/DynamicComponent";
import DesktopNavbar from "./DesktopNavbar";
import MobileNavbar from "./MobileNavbar";
import "./index.less";
export default function ApplicationLayout({ children }) {
const mobileNavbarContainerRef = useRef();
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
return (
<React.Fragment>
<DynamicComponent name="ApplicationWrapper">
<div className="application-layout-side-menu">
<DynamicComponent name="ApplicationDesktopNavbar">
<DesktopNavbar />
</DynamicComponent>
</div>
<div className="application-layout-content">
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
</DynamicComponent>
</nav>
{children}
</div>
</DynamicComponent>
</React.Fragment>
);
}
ApplicationLayout.propTypes = {
children: PropTypes.node,
};
ApplicationLayout.defaultProps = {
children: null,
};

View File

@@ -0,0 +1,81 @@
@mobileBreakpoint: ~"(max-width: 767px)";
body #application-root {
@topMenuHeight: 49px;
display: flex;
flex-direction: row;
justify-content: stretch;
padding-bottom: 0 !important;
height: 100vh;
.application-layout-side-menu {
height: 100vh;
position: relative;
@media @mobileBreakpoint {
display: none;
}
}
.application-layout-top-menu {
height: @topMenuHeight;
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
z-index: 1000;
@media @mobileBreakpoint {
display: block;
}
}
.application-layout-content {
display: flex;
flex-direction: column;
overflow-y: auto;
flex: 1 1 auto;
padding-bottom: 15px;
@media @mobileBreakpoint {
margin-top: @topMenuHeight; // compensate for app header fixed position
}
}
}
body.fixed-layout #application-root {
.application-layout-content {
padding-bottom: 0;
}
}
body.headless #application-root {
.application-layout-side-menu,
.application-layout-top-menu {
display: none !important;
}
.application-layout-content {
margin-top: 0;
}
}
// Fixes for proper snapshots in Percy (move vertical scroll to body level
// to capture entire page, otherwise it wll be cut by viewport)
@media only percy {
body #application-root {
height: auto;
.application-layout-side-menu {
height: auto;
}
.application-layout-content {
overflow: visible;
}
}
}

View File

@@ -1,7 +1,11 @@
import { isObject, get } from "lodash"; import { get, isObject } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import "./ErrorMessage.less";
import DynamicComponent from "@/components/DynamicComponent";
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
function getErrorMessageByStatus(status, defaultMessage) { function getErrorMessageByStatus(status, defaultMessage) {
switch (status) { switch (status) {
case 404: case 404:
@@ -14,7 +18,7 @@ function getErrorMessageByStatus(status, defaultMessage) {
} }
} }
export function getErrorMessage(error) { function getErrorMessage(error) {
const message = "It seems like we encountered an error. Try refreshing this page or contact your administrator."; const message = "It seems like we encountered an error. Try refreshing this page or contact your administrator.";
if (isObject(error)) { if (isObject(error)) {
// HTTP errors // HTTP errors
@@ -29,25 +33,30 @@ export function getErrorMessage(error) {
return message; return message;
} }
export default function ErrorMessage({ error }) { export default function ErrorMessage({ error, message }) {
if (!error) { if (!error) {
return null; return null;
} }
console.error(error); console.error(error);
const errorDetailsProps = {
error,
message: message || getErrorMessage(error),
};
return ( return (
<div className="fixed-container" data-test="ErrorMessage"> <div className="error-message-container" data-test="ErrorMessage" role="alert">
<div className="container">
<div className="col-md-8 col-md-push-2">
<div className="error-state bg-white tiled"> <div className="error-state bg-white tiled">
<div className="error-state__icon"> <div className="error-state__icon">
<i className="zmdi zmdi-alert-circle-o" /> <i className="zmdi zmdi-alert-circle-o" />
</div> </div>
<div className="error-state__details"> <div className="error-state__details">
<h4>{getErrorMessage(error)}</h4> <DynamicComponent
</div> name="ErrorMessageDetails"
</div> fallback={<ErrorMessageDetails {...errorDetailsProps} />}
{...errorDetailsProps}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -56,4 +65,5 @@ export default function ErrorMessage({ error }) {
ErrorMessage.propTypes = { ErrorMessage.propTypes = {
error: PropTypes.object.isRequired, error: PropTypes.object.isRequired,
message: PropTypes.string,
}; };

View File

@@ -0,0 +1,17 @@
.error-message-container {
width: 100%;
padding: 0 15px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
.error-state {
max-width: 1200px;
width: 100%;
@media (min-width: 768px) {
width: 65%;
}
}
}

View File

@@ -0,0 +1,51 @@
import React from "react";
import { mount } from "enzyme";
import ErrorMessage from "./ErrorMessage";
const ErrorMessages = {
UNAUTHORIZED: "It seems like you dont have permission to see this page.",
NOT_FOUND: "It seems like the page you're looking for cannot be found.",
GENERIC: "It seems like we encountered an error. Try refreshing this page or contact your administrator.",
};
function mockAxiosError(status = 500, response = {}) {
const error = new Error(`Failed with code ${status}.`);
error.isAxiosError = true;
error.response = { status, ...response };
return error;
}
describe("Error Message", () => {
const spyError = jest.spyOn(console, "error");
beforeEach(() => {
spyError.mockReset();
});
function expectErrorMessageToBe(error, errorMessage) {
const component = mount(<ErrorMessage error={error} />);
expect(component.find(".error-state__details h4").text()).toBe(errorMessage);
expect(spyError).toHaveBeenCalledWith(error);
}
test("displays a generic message on adhoc errors", () => {
expectErrorMessageToBe(new Error("technical information"), ErrorMessages.GENERIC);
});
test("displays a not found message on axios errors with 404 code", () => {
expectErrorMessageToBe(mockAxiosError(404), ErrorMessages.NOT_FOUND);
});
test("displays a unauthorized message on axios errors with 401 code", () => {
expectErrorMessageToBe(mockAxiosError(401), ErrorMessages.UNAUTHORIZED);
});
test("displays a unauthorized message on axios errors with 403 code", () => {
expectErrorMessageToBe(mockAxiosError(403), ErrorMessages.UNAUTHORIZED);
});
test("displays a generic message on axios errors with 500 code", () => {
expectErrorMessageToBe(mockAxiosError(500), ErrorMessages.GENERIC);
});
});

View File

@@ -0,0 +1,11 @@
import React from "react";
import PropTypes from "prop-types";
export function ErrorMessageDetails(props) {
return <h4>{props.message}</h4>;
}
ErrorMessageDetails.propTypes = {
error: PropTypes.instanceOf(Error).isRequired,
message: PropTypes.string.isRequired,
};

View File

@@ -1,8 +1,8 @@
import { isFunction, map, fromPairs, extend, startsWith, trimStart, trimEnd } from "lodash"; import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef, useContext } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import UniversalRouter from "universal-router"; import UniversalRouter from "universal-router";
import ErrorBoundary from "@/components/ErrorBoundary"; import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
import location from "@/services/location"; import location from "@/services/location";
import url from "@/services/url"; import url from "@/services/url";
@@ -14,6 +14,12 @@ function generateRouteKey() {
.substr(2); .substr(2);
} }
export const CurrentRouteContext = React.createContext(null);
export function useCurrentRoute() {
return useContext(CurrentRouteContext);
}
export function stripBase(href) { export function stripBase(href) {
// Resolve provided link and '' (root) relative to document's base. // Resolve provided link and '' (root) relative to document's base.
// If provided href is not related to current document (does not // If provided href is not related to current document (does not
@@ -30,18 +36,6 @@ export function stripBase(href) {
return false; return false;
} }
function resolveRouteDependencies(route) {
return Promise.all(
map(route.resolve, (value, key) => {
value = isFunction(value) ? value(route.routeParams, route, location) : value;
return Promise.resolve(value).then(result => [key, result]);
})
).then(results => {
route.routeParams = extend(route.routeParams, fromPairs(results));
return route;
});
}
export default function Router({ routes, onRouteChange }) { export default function Router({ routes, onRouteChange }) {
const [currentRoute, setCurrentRoute] = useState(null); const [currentRoute, setCurrentRoute] = useState(null);
@@ -65,7 +59,7 @@ export default function Router({ routes, onRouteChange }) {
errorHandlerRef.current.reset(); errorHandlerRef.current.reset();
} }
const pathname = stripBase(location.path); const pathname = stripBase(location.path) || "/";
// This is a optimization for route resolver: if current route was already resolved // This is a optimization for route resolver: if current route was already resolved
// from this path - do nothing. It also prevents router from using outdated route in a case // from this path - do nothing. It also prevents router from using outdated route in a case
@@ -86,10 +80,7 @@ export default function Router({ routes, onRouteChange }) {
router router
.resolve({ pathname }) .resolve({ pathname })
.then(route => { .then(route => {
return isAbandoned || currentPathRef.current !== pathname ? null : resolveRouteDependencies(route); if (!isAbandoned && currentPathRef.current === pathname) {
})
.then(route => {
if (route) {
setCurrentRoute({ ...route, key: generateRouteKey() }); setCurrentRoute({ ...route, key: generateRouteKey() });
} }
}) })
@@ -110,6 +101,7 @@ export default function Router({ routes, onRouteChange }) {
return () => { return () => {
isAbandoned = true; isAbandoned = true;
currentPathRef.current = null;
unlisten(); unlisten();
}; };
}, [routes]); }, [routes]);
@@ -123,9 +115,11 @@ export default function Router({ routes, onRouteChange }) {
} }
return ( return (
<CurrentRouteContext.Provider value={currentRoute}>
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}> <ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
{currentRoute.render(currentRoute)} {currentRoute.render(currentRoute)}
</ErrorBoundary> </ErrorBoundary>
</CurrentRouteContext.Provider>
); );
} }

View File

@@ -9,7 +9,7 @@ export default function handleNavigationIntent(event) {
} }
element = element.parentNode; element = element.parentNode;
} }
if (!element || !element.hasAttribute("href")) { if (!element || !element.hasAttribute("href") || element.hasAttribute("download") || element.dataset.skipRouter) {
return; return;
} }

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import routes from "@/pages"; import routes from "@/services/routes";
import Router from "./Router"; import Router from "./Router";
import handleNavigationIntent from "./handleNavigationIntent"; import handleNavigationIntent from "./handleNavigationIntent";
import ErrorMessage from "./ErrorMessage"; import ErrorMessage from "./ErrorMessage";
@@ -33,5 +33,5 @@ export default function ApplicationArea() {
return <ErrorMessage error={unhandledError} />; return <ErrorMessage error={unhandledError} />;
} }
return <Router routes={routes} onRouteChange={setCurrentRoute} />; return <Router routes={routes.items} onRouteChange={setCurrentRoute} />;
} }

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useContext } from "react"; import React, { useEffect, useState, useContext } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { ErrorBoundaryContext } from "@/components/ErrorBoundary"; import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth"; import { Auth, clientConfig } from "@/services/auth";
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object // This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
// that contains: // that contains:
@@ -33,7 +33,7 @@ function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }) {
}; };
}, [apiKey]); }, [apiKey]);
if (!isAuthenticated) { if (!isAuthenticated || clientConfig.disablePublicUrls) {
return null; return null;
} }

View File

@@ -1,82 +0,0 @@
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import ErrorBoundary, { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { Auth } from "@/services/auth";
import organizationStatus from "@/services/organizationStatus";
import ApplicationHeader from "./ApplicationHeader";
import ErrorMessage from "./ErrorMessage";
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
// that contains:
// - `currentRoute.routeParams`
// - `pageTitle` field which is equal to `currentRoute.title`
// - `onError` field which is a `handleError` method of nearest error boundary
function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
useEffect(() => {
let isCancelled = false;
Promise.all([Auth.requireSession(), organizationStatus.refresh()])
.then(() => {
if (!isCancelled) {
setIsAuthenticated(!!Auth.isAuthenticated());
}
})
.catch(() => {
if (!isCancelled) {
setIsAuthenticated(false);
}
});
return () => {
isCancelled = true;
};
}, []);
useEffect(() => {
if (bodyClass) {
document.body.classList.toggle(bodyClass, true);
return () => {
document.body.classList.toggle(bodyClass, false);
};
}
}, [bodyClass]);
if (!isAuthenticated) {
return null;
}
return (
<React.Fragment>
<ApplicationHeader />
<React.Fragment key={currentRoute.key}>
<ErrorBoundary renderError={error => <ErrorMessage error={error} />}>
<ErrorBoundaryContext.Consumer>
{({ handleError }) =>
renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
}
</ErrorBoundaryContext.Consumer>
</ErrorBoundary>
</React.Fragment>
</React.Fragment>
);
}
UserSessionWrapper.propTypes = {
bodyClass: PropTypes.string,
renderChildren: PropTypes.func,
};
UserSessionWrapper.defaultProps = {
bodyClass: null,
renderChildren: () => null,
};
export default function routeWithUserSession({ render, bodyClass, ...rest }) {
return {
...rest,
render: currentRoute => (
<UserSessionWrapper bodyClass={bodyClass} currentRoute={currentRoute} renderChildren={render} />
),
};
}

View File

@@ -0,0 +1,108 @@
import React, { useEffect, useState } from "react";
// @ts-expect-error (Must be removed after adding @redash/viz typing)
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth";
import { policy } from "@/services/policy";
import { CurrentRoute } from "@/services/routes";
import organizationStatus from "@/services/organizationStatus";
import DynamicComponent from "@/components/DynamicComponent";
import ApplicationLayout from "./ApplicationLayout";
import ErrorMessage from "./ErrorMessage";
export type UserSessionWrapperRenderChildrenProps<P> = {
pageTitle?: string;
onError: (error: Error) => void;
} & P;
export interface UserSessionWrapperProps<P> {
render: (props: UserSessionWrapperRenderChildrenProps<P>) => React.ReactNode;
currentRoute: CurrentRoute<P>;
bodyClass?: string;
}
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
// that contains:
// - `currentRoute.routeParams`
// - `pageTitle` field which is equal to `currentRoute.title`
// - `onError` field which is a `handleError` method of nearest error boundary
export function UserSessionWrapper<P>({ bodyClass, currentRoute, render }: UserSessionWrapperProps<P>) {
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
useEffect(() => {
let isCancelled = false;
Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()])
.then(() => {
if (!isCancelled) {
setIsAuthenticated(!!Auth.isAuthenticated());
}
})
.catch(() => {
if (!isCancelled) {
setIsAuthenticated(false);
}
});
return () => {
isCancelled = true;
};
}, []);
useEffect(() => {
if (bodyClass) {
document.body.classList.toggle(bodyClass, true);
return () => {
document.body.classList.toggle(bodyClass, false);
};
}
}, [bodyClass]);
if (!isAuthenticated) {
return null;
}
return (
<ApplicationLayout>
<React.Fragment key={currentRoute.key}>
<ErrorBoundary renderError={(error: Error) => <ErrorMessage error={error} />}>
<ErrorBoundaryContext.Consumer>
{({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps<P>["onError"] }) =>
render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })
}
</ErrorBoundaryContext.Consumer>
</ErrorBoundary>
</React.Fragment>
</ApplicationLayout>
);
}
export type RouteWithUserSessionOptions<P> = {
render: (props: UserSessionWrapperRenderChildrenProps<P>) => React.ReactNode;
bodyClass?: string;
title: string;
path: string;
};
export const UserSessionWrapperDynamicComponentName = "UserSessionWrapper";
export default function routeWithUserSession<P extends {} = {}>({
render: originalRender,
bodyClass,
...rest
}: RouteWithUserSessionOptions<P>) {
return {
...rest,
render: (currentRoute: CurrentRoute<P>) => {
const props = {
render: originalRender,
bodyClass,
currentRoute,
};
return (
<DynamicComponent
{...props}
name={UserSessionWrapperDynamicComponentName}
fallback={<UserSessionWrapper {...props} />}
/>
);
},
};
}

View File

@@ -3,6 +3,7 @@ import Card from "antd/lib/card";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Typography from "antd/lib/typography"; import Typography from "antd/lib/typography";
import { clientConfig } from "@/services/auth"; import { clientConfig } from "@/services/auth";
import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import OrgSettings from "@/services/organizationSettings"; import OrgSettings from "@/services/organizationSettings";
@@ -65,8 +66,8 @@ 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 <a href="settings/organization">Organization Settings</a>{" "} You can change this setting anytime from the{" "}
page. <Link href="settings/organization">Organization Settings</Link> page.
</Text> </Text>
</div> </div>
</Card> </Card>

View File

@@ -2,6 +2,7 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
import "./CodeBlock.less"; import "./CodeBlock.less";
export default class CodeBlock extends React.Component { export default class CodeBlock extends React.Component {
@@ -59,7 +60,7 @@ export default class CodeBlock extends React.Component {
const copyButton = ( const copyButton = (
<Tooltip title={this.state.copied || "Copy"}> <Tooltip title={this.state.copied || "Copy"}>
<Button icon="copy" type="dashed" size="small" onClick={this.copy} /> <Button icon={<CopyOutlinedIcon />} type="dashed" size="small" onClick={this.copy} />
</Tooltip> </Tooltip>
); );

View File

@@ -1,13 +1,13 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { isEmpty, toUpper, includes } from "lodash"; import { isEmpty, toUpper, includes, get } from "lodash";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import List from "antd/lib/list"; import List from "antd/lib/list";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import Steps from "antd/lib/steps"; import Steps from "antd/lib/steps";
import { getErrorMessage } from "@/components/ApplicationArea/ErrorMessage";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import Link from "@/components/Link";
import { PreviewCard } from "@/components/PreviewCard"; import { PreviewCard } from "@/components/PreviewCard";
import EmptyState from "@/components/items-list/components/EmptyState"; import EmptyState from "@/components/items-list/components/EmptyState";
import DynamicForm from "@/components/dynamic-form/DynamicForm"; import DynamicForm from "@/components/dynamic-form/DynamicForm";
@@ -67,7 +67,7 @@ class CreateSourceDialog extends React.Component {
}) })
.catch(error => { .catch(error => {
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT }); this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
errorCallback(getErrorMessage(error.message)); errorCallback(get(error, "response.data.message", "Failed saving."));
}); });
} }
}; };
@@ -116,6 +116,15 @@ class CreateSourceDialog extends React.Component {
)} )}
</div> </div>
<DynamicForm id="sourceForm" fields={fields} onSubmit={this.createSource} feedbackIcons hideSubmitButton /> <DynamicForm id="sourceForm" fields={fields} onSubmit={this.createSource} feedbackIcons hideSubmitButton />
{selectedType.type === "databricks" && (
<small>
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
<Link href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
Driver Download Terms and Conditions
</Link>
.
</small>
)}
</div> </div>
); );
} }
@@ -124,7 +133,12 @@ class CreateSourceDialog extends React.Component {
const { imageFolder } = this.props; const { imageFolder } = this.props;
return ( return (
<List.Item className="p-l-10 p-r-10 clickable" onClick={() => this.selectType(item)}> <List.Item className="p-l-10 p-r-10 clickable" onClick={() => this.selectType(item)}>
<PreviewCard title={item.name} imageUrl={`${imageFolder}/${item.type}.png`} roundedImage={false}> <PreviewCard
title={item.name}
imageUrl={`${imageFolder}/${item.type}.png`}
roundedImage={false}
data-test="PreviewItem"
data-test-type={item.type}>
<i className="fa fa-angle-double-right" /> <i className="fa fa-angle-double-right" />
</PreviewCard> </PreviewCard>
</List.Item> </List.Item>
@@ -141,7 +155,7 @@ class CreateSourceDialog extends React.Component {
footer={ footer={
currentStep === StepEnum.SELECT_TYPE currentStep === StepEnum.SELECT_TYPE
? [ ? [
<Button key="cancel" onClick={() => dialog.dismiss()}> <Button key="cancel" onClick={() => dialog.dismiss()} data-test="CreateSourceCancelButton">
Cancel Cancel
</Button>, </Button>,
<Button key="submit" type="primary" disabled> <Button key="submit" type="primary" disabled>
@@ -158,7 +172,7 @@ class CreateSourceDialog extends React.Component {
form="sourceForm" form="sourceForm"
type="primary" type="primary"
loading={savingSource} loading={savingSource}
data-test="CreateSourceButton"> data-test="CreateSourceSaveButton">
Create Create
</Button>, </Button>,
] ]

View File

@@ -0,0 +1,30 @@
import { ModalProps } from "antd/lib/modal/Modal";
export interface DialogProps<ROk, RCancel> {
props: ModalProps;
close: (result: ROk) => void;
dismiss: (result: RCancel) => void;
}
export type DialogWrapperChildProps<ROk, RCancel> = {
dialog: DialogProps<ROk, RCancel>;
};
export type DialogComponentType<ROk = void, P = {}, RCancel = void> = React.ComponentType<
DialogWrapperChildProps<ROk, RCancel> & P
>;
export function wrap<ROk = void, P = {}, RCancel = void>(
DialogComponent: DialogComponentType<ROk, P, RCancel>
): {
Component: DialogComponentType<ROk, P, RCancel>;
showModal: (
props?: P
) => {
update: (props: P) => void;
onClose: (handler: (result: ROk) => Promise<void> | void) => void;
onDismiss: (handler: (result: RCancel) => Promise<void> | void) => void;
close: (result: ROk) => void;
dismiss: (result: RCancel) => void;
};
};

View File

@@ -14,9 +14,10 @@ import ReactDOM from "react-dom";
{ {
showModal: (dialogProps) => object({ showModal: (dialogProps) => object({
result: Promise,
close: (result) => void, close: (result) => void,
dismiss: (reason) => void, dismiss: (reason) => void,
onClose: (handler) => this,
onDismiss: (handler) => this,
}), }),
Component: React.Component, // wrapped dialog component Component: React.Component, // wrapped dialog component
} }
@@ -28,15 +29,20 @@ import ReactDOM from "react-dom";
const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' }) const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' })
To get result of modal, use `result` property: To get result of modal, use `onClose`/`onDismiss` setters:
dialog.result dialog
.then(...) // pressed OK button or used `close` method; resolved value is a result of dialog .onClose(result => { ... }) // pressed OK button or used `close` method
.catch(...) // pressed Cancel button or used `dismiss` method; optional argument is a rejection reason. .onDismiss(result => { ... }) // pressed Cancel button or used `dismiss` method
If `onClose`/`onDismiss` returns a promise - dialog wrapper will stop handling further close/dismiss
requests and will show loader on a corresponding button until that promise is fulfilled (either resolved or
rejected). If that promise will be rejected - dialog close/dismiss will be abandoned. Use promise returned
from `close`/`dismiss` methods to handle errors (if needed).
Also, dialog has `close` and `dismiss` methods that allows to close dialog by caller. Passed arguments Also, dialog has `close` and `dismiss` methods that allows to close dialog by caller. Passed arguments
will be used to resolve/reject `dialog.result` promise. `update` methods allows to pass new properties will be passed to a corresponding handler. Both methods will return the promise returned from `onClose` and
to dialog. `onDismiss` callbacks. `update` method allows to pass new properties to dialog.
Creating a dialog Creating a dialog
@@ -88,21 +94,6 @@ import ReactDOM from "react-dom";
<Modal {...dialog.props} onOk={() => this.customOkHandler()}> <Modal {...dialog.props} onOk={() => this.customOkHandler()}>
); );
} }
Settings
========
You can setup this wrapper to use custom `Promise` library (for example, Bluebird):
import DialogWrapper from 'path/to/DialogWrapper';
import Promise from 'bluebird';
DialogWrapper.Promise = Promise;
It could be useful to avoid `unhandledrejection` exception that would fire with native Promises,
or when some custom Promise library is used in application.
*/ */
export const DialogPropType = PropTypes.shape({ export const DialogPropType = PropTypes.shape({
@@ -116,17 +107,12 @@ export const DialogPropType = PropTypes.shape({
dismiss: PropTypes.func.isRequired, dismiss: PropTypes.func.isRequired,
}); });
// default export of module
const DialogWrapper = {
Promise,
DialogPropType,
wrap() {},
};
function openDialog(DialogComponent, props) { function openDialog(DialogComponent, props) {
const dialog = { const dialog = {
props: { props: {
visible: true, visible: true,
okButtonProps: {},
cancelButtonProps: {},
onOk: () => {}, onOk: () => {},
onCancel: () => {}, onCancel: () => {},
afterClose: () => {}, afterClose: () => {},
@@ -135,9 +121,11 @@ function openDialog(DialogComponent, props) {
dismiss: () => {}, dismiss: () => {},
}; };
const dialogResult = { let pendingCloseTask = null;
resolve: () => {},
reject: () => {}, const handlers = {
onClose: () => {},
onDismiss: () => {},
}; };
const container = document.createElement("div"); const container = document.createElement("div");
@@ -155,16 +143,43 @@ function openDialog(DialogComponent, props) {
}, 10); }, 10);
} }
function closeDialog(result) { function processDialogClose(result, setAdditionalDialogProps) {
dialogResult.resolve(result); dialog.props.okButtonProps = { disabled: true };
dialog.props.visible = false; dialog.props.cancelButtonProps = { disabled: true };
setAdditionalDialogProps();
render(); render();
return Promise.resolve(result)
.then(() => {
dialog.props.visible = false;
})
.finally(() => {
dialog.props.okButtonProps = {};
dialog.props.cancelButtonProps = {};
render();
});
} }
function dismissDialog(reason) { function closeDialog(result) {
dialogResult.reject(reason); if (!pendingCloseTask) {
dialog.props.visible = false; pendingCloseTask = processDialogClose(handlers.onClose(result), () => {
render(); dialog.props.okButtonProps.loading = true;
}).finally(() => {
pendingCloseTask = null;
});
}
return pendingCloseTask;
}
function dismissDialog(result) {
if (!pendingCloseTask) {
pendingCloseTask = processDialogClose(handlers.onDismiss(result), () => {
dialog.props.cancelButtonProps.loading = true;
}).finally(() => {
pendingCloseTask = null;
});
}
return pendingCloseTask;
} }
dialog.props.onOk = closeDialog; dialog.props.onOk = closeDialog;
@@ -180,20 +195,22 @@ function openDialog(DialogComponent, props) {
props = { ...props, ...newProps }; props = { ...props, ...newProps };
render(); render();
}, },
result: new DialogWrapper.Promise((resolve, reject) => { onClose: handler => {
dialogResult.resolve = resolve; if (isFunction(handler)) {
dialogResult.reject = reject; handlers.onClose = handler;
}), }
return result;
},
onDismiss: handler => {
if (isFunction(handler)) {
handlers.onDismiss = handler;
}
return result;
},
}; };
render(); // show it only when all structures initialized to avoid unnecessary re-rendering render(); // show it only when all structures initialized to avoid unnecessary re-rendering
// Some known libraries support
// Bluebird: http://bluebirdjs.com/docs/api/suppressunhandledrejections.html
if (isFunction(result.result.suppressUnhandledRejections)) {
result.result.suppressUnhandledRejections();
}
return result; return result;
} }
@@ -204,6 +221,7 @@ export function wrap(DialogComponent) {
}; };
} }
DialogWrapper.wrap = wrap; export default {
DialogPropType,
export default DialogWrapper; wrap,
};

View File

@@ -1,4 +1,4 @@
import { isFunction, isString } from "lodash"; import { isFunction, isString, isUndefined } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
@@ -24,6 +24,7 @@ export function unregisterComponent(name) {
export default class DynamicComponent extends React.Component { export default class DynamicComponent extends React.Component {
static propTypes = { static propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
fallback: PropTypes.node,
children: PropTypes.node, children: PropTypes.node,
}; };
@@ -40,10 +41,11 @@ export default class DynamicComponent extends React.Component {
} }
render() { render() {
const { name, children, ...props } = this.props; const { name, children, fallback, ...props } = this.props;
const RealComponent = componentsRegistry.get(name); const RealComponent = componentsRegistry.get(name);
if (!RealComponent) { if (!RealComponent) {
return children; // return fallback if any, otherwise return children
return isUndefined(fallback) ? children : fallback;
} }
return <RealComponent {...props}>{children}</RealComponent>; return <RealComponent {...props}>{children}</RealComponent>;
} }

View File

@@ -11,8 +11,10 @@ export default class EditInPlace extends React.Component {
placeholder: PropTypes.string, placeholder: PropTypes.string,
value: PropTypes.string, value: PropTypes.string,
onDone: PropTypes.func.isRequired, onDone: PropTypes.func.isRequired,
onStopEditing: PropTypes.func,
multiline: PropTypes.bool, multiline: PropTypes.bool,
editorProps: PropTypes.object, editorProps: PropTypes.object,
defaultEditing: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@@ -20,21 +22,22 @@ export default class EditInPlace extends React.Component {
isEditable: true, isEditable: true,
placeholder: "", placeholder: "",
value: "", value: "",
onStopEditing: () => {},
multiline: false, multiline: false,
editorProps: {}, editorProps: {},
defaultEditing: false,
}; };
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
editing: false, editing: props.defaultEditing,
}; };
this.inputRef = React.createRef();
} }
componentDidUpdate(_, prevState) { componentDidUpdate(_, prevState) {
if (this.state.editing && !prevState.editing) { if (!this.state.editing && prevState.editing) {
this.inputRef.current.focus(); this.props.onStopEditing();
} }
} }
@@ -62,14 +65,19 @@ export default class EditInPlace extends React.Component {
} }
}; };
renderNormal = () => ( renderNormal = () =>
this.props.value ? (
<span <span
role="presentation" role="presentation"
onFocus={this.startEditing} onFocus={this.startEditing}
onClick={this.startEditing} onClick={this.startEditing}
className={this.props.isEditable ? "editable" : ""}> className={this.props.isEditable ? "editable" : ""}>
{this.props.value || this.props.placeholder} {this.props.value}
</span> </span>
) : (
<a className="clickable" onClick={this.startEditing}>
{this.props.placeholder}
</a>
); );
renderEdit = () => { renderEdit = () => {
@@ -77,10 +85,10 @@ export default class EditInPlace extends React.Component {
const InputComponent = multiline ? Input.TextArea : Input; const InputComponent = multiline ? Input.TextArea : Input;
return ( return (
<InputComponent <InputComponent
ref={this.inputRef}
defaultValue={value} defaultValue={value}
onBlur={e => this.stopEditing(e.target.value)} onBlur={e => this.stopEditing(e.target.value)}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
autoFocus
{...editorProps} {...editorProps}
/> />
); );

View File

@@ -1,5 +1,5 @@
import { includes, words, capitalize, clone, isNull } from "lodash"; import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Checkbox from "antd/lib/checkbox"; import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
@@ -11,6 +11,8 @@ import Divider from "antd/lib/divider";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; 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 { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
const { Option } = Select; const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } }; const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
@@ -69,17 +71,27 @@ NameInput.propTypes = {
function EditParameterSettingsDialog(props) { 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 [paramQuery, setParamQuery] = useState();
const mappingParameters = useMemo(
() =>
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
mappingParam,
existingMapping: get(param.parameterMapping, mappingParam.name, {
mappingType: QueryBasedParameterMappingType.UNDEFINED,
}),
})),
[param.parameterMapping, paramQuery]
);
const isNew = !props.parameter.name; const isNew = !props.parameter.name;
// fetch query by id // fetch query by id
const initialQueryId = useRef(props.parameter.queryId);
useEffect(() => { useEffect(() => {
const queryId = props.parameter.queryId; if (initialQueryId.current) {
if (queryId) { Query.get({ id: initialQueryId.current }).then(setParamQuery);
Query.get({ id: queryId }).then(setInitialQuery);
} }
}, [props.parameter.queryId]); }, []);
function isFulfilled() { function isFulfilled() {
// name // name
@@ -93,14 +105,20 @@ function EditParameterSettingsDialog(props) {
} }
// query // query
if (param.type === "query" && !param.queryId) { if (param.type === "query") {
if (!param.queryId) {
return false; return false;
} }
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
return false;
}
}
return true; return true;
} }
function onConfirm(e) { function onConfirm() {
// update title to default // update title to default
if (!param.title) { if (!param.title) {
// forced to do this cause param won't update in time for save // forced to do this cause param won't update in time for save
@@ -109,8 +127,6 @@ function EditParameterSettingsDialog(props) {
} }
props.dialog.close(param); props.dialog.close(param);
e.preventDefault(); // stops form redirect
} }
return ( return (
@@ -132,7 +148,7 @@ function EditParameterSettingsDialog(props) {
{isNew ? "Add Parameter" : "OK"} {isNew ? "Add Parameter" : "OK"}
</Button>, </Button>,
]}> ]}>
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm"> <Form layout="horizontal" onFinish={onConfirm} id="paramForm">
{isNew && ( {isNew && (
<NameInput <NameInput
name={param.name} name={param.name}
@@ -142,7 +158,7 @@ function EditParameterSettingsDialog(props) {
type={param.type} type={param.type}
/> />
)} )}
<Form.Item 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 })}
@@ -189,14 +205,28 @@ function EditParameterSettingsDialog(props) {
</Form.Item> </Form.Item>
)} )}
{param.type === "query" && ( {param.type === "query" && (
<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" required {...formItemProps}>
<QuerySelector <QuerySelector
selectedQuery={initialQuery} selectedQuery={paramQuery}
onChange={q => setParam({ ...param, queryId: q && q.id })} onChange={q => {
if (q) {
setParamQuery(q);
setParam({ ...param, queryId: q.id, parameterMapping: {} });
}
}}
type="select" type="select"
/> />
</Form.Item> </Form.Item>
)} )}
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
<QueryBasedParameterMappingTable
param={param}
mappingParameters={mappingParameters}
onChangeParam={setParam}
/>
</Form.Item>
)}
{(param.type === "enum" || param.type === "query") && ( {(param.type === "enum" || param.type === "query") && (
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}> <Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
<Checkbox <Checkbox

View File

@@ -0,0 +1,134 @@
import React, { useState, useEffect, useRef, useReducer } from "react";
import PropTypes from "prop-types";
import { values } from "lodash";
import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip";
import Radio from "antd/lib/radio";
import Typography from "antd/lib/typography/Typography";
import ParameterValueInput from "@/components/ParameterValueInput";
import InputPopover from "@/components/InputPopover";
import Form from "antd/lib/form";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
const { Text } = Typography;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
const [showPopover, setShowPopover] = useState(false);
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
const newMappingRef = useRef(newMapping);
useEffect(() => {
if (
mapping.mappingType !== newMappingRef.current.mappingType ||
mapping.staticValue !== newMappingRef.current.staticValue
) {
setNewMapping(mapping);
}
}, [mapping]);
const parameterRef = useRef(parameter);
useEffect(() => {
parameterRef.current.setValue(mapping.staticValue);
}, [mapping.staticValue]);
const onCancel = () => {
setNewMapping(mapping);
setShowPopover(false);
};
const onOk = () => {
onChange(newMapping);
setShowPopover(false);
};
let currentState = <Text type="secondary">Pick a type</Text>;
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
currentState = "Dropdown Search";
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
currentState = `Value: ${mapping.staticValue}`;
}
return (
<>
{currentState}
<InputPopover
placement="left"
trigger="click"
header="Edit Parameter Source"
okButtonProps={{
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
}}
onOk={onOk}
onCancel={onCancel}
content={
<Form>
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
<Radio.Group
value={newMapping.mappingType}
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
<Radio
className="radio"
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
disabled={!searchAvailable || parameter.type !== "text"}>
Dropdown Search{" "}
{(!searchAvailable || parameter.type !== "text") && (
<Tooltip
title={
parameter.type !== "text"
? "Dropdown Search is only available for Text Parameters"
: "There is already a parameter mapped with the Dropdown Search type."
}>
<QuestionCircleFilledIcon />
</Tooltip>
)}
</Radio>
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
Static Value
</Radio>
</Radio.Group>
</Form.Item>
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
<Form.Item label="Value" required {...formItemProps}>
<ParameterValueInput
type={parameter.type}
value={parameter.normalizedValue}
enumOptions={parameter.enumOptions}
queryId={parameter.queryId}
parameter={parameter}
onSelect={value => {
parameter.setValue(value);
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
}}
/>
</Form.Item>
)}
</Form>
}
visible={showPopover}
onVisibleChange={setShowPopover}>
<Button className="m-l-5" size="small" type="dashed">
<EditOutlinedIcon />
</Button>
</InputPopover>
</>
);
}
QueryBasedParameterMappingEditor.propTypes = {
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
mapping: PropTypes.shape({
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}),
searchAvailable: PropTypes.bool,
onChange: PropTypes.func,
};
QueryBasedParameterMappingEditor.defaultProps = {
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
searchAvailable: false,
onChange: () => {},
};

View File

@@ -0,0 +1,56 @@
import React from "react";
import { findKey } from "lodash";
import PropTypes from "prop-types";
import Table from "antd/lib/table";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
return (
<Table
dataSource={mappingParameters}
size="middle"
pagination={false}
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
<Table.Column
title="Keyword"
key="keyword"
className="keyword"
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
/>
<Table.Column
title="Value Source"
key="source"
render={({ mappingParam, existingMapping }) => (
<QueryBasedParameterMappingEditor
parameter={mappingParam.setValue(existingMapping.staticValue)}
mapping={existingMapping}
searchAvailable={
!findKey(param.parameterMapping, {
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
}
onChange={mapping =>
onChangeParam({
...param,
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
})
}
/>
)}
/>
</Table>
);
}
QueryBasedParameterMappingTable.propTypes = {
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
onChangeParam: PropTypes.func,
};
QueryBasedParameterMappingTable.defaultProps = {
mappingParameters: [],
onChangeParam: () => {},
};

View File

@@ -3,7 +3,13 @@ import PropTypes from "prop-types";
import Dropdown from "antd/lib/dropdown"; import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Icon from "antd/lib/icon"; import { clientConfig } from "@/services/auth";
import PlusCircleFilledIcon from "@ant-design/icons/PlusCircleFilled";
import ShareAltOutlinedIcon from "@ant-design/icons/ShareAltOutlined";
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import QueryResultsLink from "./QueryResultsLink"; import QueryResultsLink from "./QueryResultsLink";
@@ -13,14 +19,14 @@ export default function QueryControlDropdown(props) {
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && ( {!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
<Menu.Item> <Menu.Item>
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}> <a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
<Icon type="plus-circle" theme="filled" /> Add to Dashboard <PlusCircleFilledIcon /> Add to Dashboard
</a> </a>
</Menu.Item> </Menu.Item>
)} )}
{!props.query.isNew() && ( {!clientConfig.disablePublicUrls && !props.query.isNew() && (
<Menu.Item> <Menu.Item>
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton"> <a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
<Icon type="share-alt" /> Embed Elsewhere <ShareAltOutlinedIcon /> Embed Elsewhere
</a> </a>
</Menu.Item> </Menu.Item>
)} )}
@@ -32,7 +38,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult} queryResult={props.queryResult}
embed={props.embed} embed={props.embed}
apiKey={props.apiKey}> apiKey={props.apiKey}>
<Icon type="file" /> Download as CSV File <FileOutlinedIcon /> Download as CSV File
</QueryResultsLink> </QueryResultsLink>
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
@@ -43,7 +49,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult} queryResult={props.queryResult}
embed={props.embed} embed={props.embed}
apiKey={props.apiKey}> apiKey={props.apiKey}>
<Icon type="file" /> Download as TSV File <FileOutlinedIcon /> Download as TSV File
</QueryResultsLink> </QueryResultsLink>
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
@@ -54,7 +60,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult} queryResult={props.queryResult}
embed={props.embed} embed={props.embed}
apiKey={props.apiKey}> apiKey={props.apiKey}>
<Icon type="file-excel" /> Download as Excel File <FileExcelOutlinedIcon /> Download as Excel File
</QueryResultsLink> </QueryResultsLink>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
@@ -63,7 +69,7 @@ export default function QueryControlDropdown(props) {
return ( return (
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay"> <Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
<Button data-test="QueryControlDropdownButton"> <Button data-test="QueryControlDropdownButton">
<Icon type="ellipsis" rotate={90} /> <EllipsisOutlinedIcon rotate={90} />
</Button> </Button>
</Dropdown> </Dropdown>
); );

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Link from "@/components/Link";
export default function QueryResultsLink(props) { export default function QueryResultsLink(props) {
let href = ""; let href = "";
@@ -17,9 +18,9 @@ export default function QueryResultsLink(props) {
} }
return ( return (
<a target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download> <Link target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
{props.children} {props.children}
</a> </Link>
); );
} }

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Icon from "antd/lib/icon"; import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
export default function EditVisualizationButton(props) { export default function EditVisualizationButton(props) {
return ( return (
@@ -9,7 +9,7 @@ export default function EditVisualizationButton(props) {
data-test="EditVisualization" data-test="EditVisualization"
className="edit-visualization" className="edit-visualization"
onClick={() => props.openVisualizationEditor(props.selectedTab)}> onClick={() => props.openVisualizationEditor(props.selectedTab)}>
<Icon type="form" /> <FormOutlinedIcon />
<span className="hidden-xs hidden-s hidden-m">Edit Visualization</span> <span className="hidden-xs hidden-s hidden-m">Edit Visualization</span>
</Button> </Button>
); );

View File

@@ -74,7 +74,7 @@ function Filters({ filters, onChange }) {
onChange = createFilterChangeHandler(filters, onChange); onChange = createFilterChangeHandler(filters, onChange);
return ( return (
<div className="filters-wrapper"> <div className="filters-wrapper" data-test="Filters">
<div className="container bg-white"> <div className="container bg-white">
<div className="row"> <div className="row">
{map(filters, filter => { {map(filters, filter => {
@@ -83,7 +83,10 @@ function Filters({ filters, onChange }) {
)); ));
return ( return (
<div key={filter.name} className="col-sm-6 p-l-0 filter-container"> <div
key={filter.name}
className="col-sm-6 p-l-0 filter-container"
data-test={`FilterName-${filter.name}`}>
<label>{filter.friendlyName}</label> <label>{filter.friendlyName}</label>
{options.length === 0 && <Select className="w-100" disabled value="No values" />} {options.length === 0 && <Select className="w-100" disabled value="No values" />}
{options.length > 0 && ( {options.length > 0 && (
@@ -102,14 +105,17 @@ function Filters({ filters, onChange }) {
allowClear={filter.multiple} allowClear={filter.multiple}
optionFilterProp="children" optionFilterProp="children"
showSearch showSearch
maxTagCount={3}
maxTagTextLength={10}
maxTagPlaceholder={num => `+${num.length} more`}
onChange={values => onChange(filter, values)}> onChange={values => onChange(filter, values)}>
{!filter.multiple && options} {!filter.multiple && options}
{filter.multiple && [ {filter.multiple && [
<Select.Option key={NONE_VALUES}> <Select.Option key={NONE_VALUES} data-test="ClearOption">
<i className="fa fa-square-o m-r-5" /> <i className="fa fa-square-o m-r-5" />
Clear Clear
</Select.Option>, </Select.Option>,
<Select.Option key={ALL_VALUES}> <Select.Option key={ALL_VALUES} data-test="SelectAllOption">
<i className="fa fa-check-square-o m-r-5" /> <i className="fa fa-check-square-o m-r-5" />
Select All Select All
</Select.Option>, </Select.Option>,

View File

@@ -1,12 +1,13 @@
import { startsWith } from "lodash"; import { startsWith, get, some, mapValues } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import cx from "classnames"; import cx from "classnames";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
import Drawer from "antd/lib/drawer"; import Drawer from "antd/lib/drawer";
import Icon from "antd/lib/icon"; import Link from "@/components/Link";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import BigMessage from "@/components/BigMessage"; import BigMessage from "@/components/BigMessage";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
import "./HelpTrigger.less"; import "./HelpTrigger.less";
@@ -15,7 +16,8 @@ const HELP_PATH = "/help";
const IFRAME_TIMEOUT = 20000; const IFRAME_TIMEOUT = 20000;
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url"; const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
export const TYPES = { export const TYPES = mapValues(
{
HOME: ["", "Help"], HOME: ["", "Help"],
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"], VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"], SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
@@ -25,7 +27,10 @@ export const TYPES = {
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"], DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"], DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"], DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
DS_GOOGLE_SPREADSHEETS: ["/data-sources/querying-a-google-spreadsheet", "Guide: Help Setting up Google Spreadsheets"], DS_GOOGLE_SPREADSHEETS: [
"/data-sources/querying-a-google-spreadsheet",
"Guide: Help Setting up Google Spreadsheets",
],
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"], DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"], DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"], DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
@@ -38,22 +43,43 @@ export const TYPES = {
"Guide: Managing Query Permissions", "Guide: Managing Query Permissions",
], ],
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"], NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
}; GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
QUERIES: ["/help/user-guide/querying", "Guide: Queries"],
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
},
([url, title]) => [DOMAIN + HELP_PATH + url, title]
);
export default class HelpTrigger extends React.Component { const HelpTriggerPropTypes = {
static propTypes = { type: PropTypes.string,
type: PropTypes.oneOf(Object.keys(TYPES)).isRequired, href: PropTypes.string,
title: PropTypes.node,
className: PropTypes.string, className: PropTypes.string,
showTooltip: PropTypes.bool, showTooltip: PropTypes.bool,
renderAsLink: PropTypes.bool,
children: PropTypes.node, children: PropTypes.node,
}; };
static defaultProps = { const HelpTriggerDefaultProps = {
type: null,
href: null,
title: null,
className: null, className: null,
showTooltip: true, showTooltip: true,
renderAsLink: false,
children: <i className="fa fa-question-circle" />, children: <i className="fa fa-question-circle" />,
}; };
export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName = null) {
return class HelpTrigger extends React.Component {
static propTypes = {
...HelpTriggerPropTypes,
type: PropTypes.oneOf(Object.keys(types)),
};
static defaultProps = HelpTriggerDefaultProps;
iframeRef = React.createRef(); iframeRef = React.createRef();
iframeLoadingTimeout = null; iframeLoadingTimeout = null;
@@ -90,7 +116,7 @@ export default class HelpTrigger extends React.Component {
}; };
onPostMessageReceived = event => { onPostMessageReceived = event => {
if (!startsWith(event.origin, DOMAIN)) { if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
return; return;
} }
@@ -102,13 +128,19 @@ export default class HelpTrigger extends React.Component {
this.setState({ currentUrl }); this.setState({ currentUrl });
}; };
openDrawer = () => { getUrl = () => {
this.setState({ visible: true }); const helpTriggerType = get(types, this.props.type);
const [pagePath] = TYPES[this.props.type]; return helpTriggerType ? helpTriggerType[0] : this.props.href;
const url = DOMAIN + HELP_PATH + pagePath; };
openDrawer = e => {
// keep "open in new tab" behavior
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.setState({ visible: true });
// wait for drawer animation to complete so there's no animation jank // wait for drawer animation to complete so there's no animation jank
setTimeout(() => this.loadIframe(url), 300); setTimeout(() => this.loadIframe(this.getUrl()), 300);
}
}; };
closeDrawer = event => { closeDrawer = event => {
@@ -120,23 +152,43 @@ export default class HelpTrigger extends React.Component {
}; };
render() { render() {
const [, tooltip] = TYPES[this.props.type]; const targetUrl = this.getUrl();
if (!targetUrl) {
return null;
}
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 shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
return ( return (
<React.Fragment> <React.Fragment>
<Tooltip title={this.props.showTooltip ? tooltip : null}> <Tooltip
<a onClick={this.openDrawer} className={className}> title={
this.props.showTooltip ? (
<>
{tooltip}
{shouldRenderAsLink && <i className="fa fa-external-link" style={{ marginLeft: 5 }} />}
</>
) : null
}>
<Link
href={url || this.getUrl()}
className={className}
rel="noopener noreferrer"
target="_blank"
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
{this.props.children} {this.props.children}
</a> </Link>
</Tooltip> </Tooltip>
<Drawer <Drawer
placement="right" placement="right"
closable={false} closable={false}
onClose={this.closeDrawer} onClose={this.closeDrawer}
visible={this.state.visible} visible={this.state.visible}
className="help-drawer" className={cx("help-drawer", drawerClassName)}
destroyOnClose destroyOnClose
width={400}> width={400}>
<div className="drawer-wrapper"> <div className="drawer-wrapper">
@@ -144,14 +196,14 @@ export default class HelpTrigger extends React.Component {
{url && ( {url && (
<Tooltip title="Open page in a new window" placement="left"> <Tooltip title="Open page in a new window" placement="left">
{/* eslint-disable-next-line react/jsx-no-target-blank */} {/* eslint-disable-next-line react/jsx-no-target-blank */}
<a href={url} target="_blank"> <Link href={url} target="_blank">
<i className="fa fa-external-link" /> <i className="fa fa-external-link" />
</a> </Link>
</Tooltip> </Tooltip>
)} )}
<Tooltip title="Close" placement="bottom"> <Tooltip title="Close" placement="bottom">
<a href="#" onClick={this.closeDrawer}> <a onClick={this.closeDrawer}>
<Icon type="close" /> <CloseOutlinedIcon />
</a> </a>
</Tooltip> </Tooltip>
</div> </div>
@@ -160,7 +212,7 @@ export default class HelpTrigger extends React.Component {
{!this.state.error && ( {!this.state.error && (
<iframe <iframe
ref={this.iframeRef} ref={this.iframeRef}
title="Redash Help" title="Usage Help"
src="about:blank" src="about:blank"
className={cx({ ready: !this.state.loading })} className={cx({ ready: !this.state.loading })}
onLoad={this.onIframeLoaded} onLoad={this.onIframeLoaded}
@@ -178,9 +230,9 @@ export default class HelpTrigger extends React.Component {
Something went wrong. Something went wrong.
<br /> <br />
{/* eslint-disable-next-line react/jsx-no-target-blank */} {/* eslint-disable-next-line react/jsx-no-target-blank */}
<a href={this.state.error} target="_blank" rel="noopener"> <Link href={this.state.error} target="_blank" rel="noopener">
Click here Click here
</a>{" "} </Link>{" "}
to open the page in a new window. to open the page in a new window.
</BigMessage> </BigMessage>
)} )}
@@ -192,4 +244,14 @@ export default class HelpTrigger extends React.Component {
</React.Fragment> </React.Fragment>
); );
} }
};
} }
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
export default function HelpTrigger(props) {
return <DynamicComponent {...props} name="HelpTrigger" />;
}
HelpTrigger.propTypes = HelpTriggerPropTypes;
HelpTrigger.defaultProps = HelpTriggerDefaultProps;

View File

@@ -1,4 +1,4 @@
@import '~antd/lib/drawer/style/drawer'; @import "~antd/lib/drawer/style/drawer";
@help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15 @help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15
@@ -34,7 +34,7 @@
top: 13px; top: 13px;
right: 13px; right: 13px;
border-radius: 3px; border-radius: 3px;
background: rgba(@help-doc-bg, .75); // makes it dissolve over help doc bg background: rgba(@help-doc-bg, 0.75); // makes it dissolve over help doc bg
border: 2px solid @help-doc-bg; border: 2px solid @help-doc-bg;
display: flex; display: flex;
@@ -47,6 +47,7 @@
color: @text-color-secondary; color: @text-color-secondary;
transition: color @animation-duration-slow; transition: color @animation-duration-slow;
position: relative; position: relative;
cursor: pointer;
&:hover { &:hover {
color: @icon-color-hover; color: @icon-color-hover;
@@ -65,13 +66,13 @@
// divider // divider
&:not(:first-child):before { &:not(:first-child):before {
content: ''; content: "";
position: absolute; position: absolute;
width: 1px; width: 1px;
height: 9px; height: 9px;
left: 0; left: 0;
top: 9px; top: 9px;
border-left: 1px dotted rgba(0,0,0,.12); border-left: 1px dotted rgba(0, 0, 0, 0.12);
} }
} }
} }

View File

@@ -0,0 +1,57 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Popover from "antd/lib/popover";
import "./index.less";
export default function InputPopover({
header,
content,
children,
okButtonProps,
cancelButtonProps,
onCancel,
onOk,
...props
}) {
return (
<Popover
{...props}
content={
<div className="input-popover-content" data-test="InputPopoverContent">
{header && <header>{header}</header>}
{content}
<footer>
<Button onClick={onCancel} {...cancelButtonProps}>
Cancel
</Button>
<Button onClick={onOk} type="primary" {...okButtonProps}>
OK
</Button>
</footer>
</div>
}>
{children}
</Popover>
);
}
InputPopover.propTypes = {
header: PropTypes.node,
content: PropTypes.node,
children: PropTypes.node,
okButtonProps: PropTypes.object,
cancelButtonProps: PropTypes.object,
onOk: PropTypes.func,
onCancel: PropTypes.func,
};
InputPopover.defaultProps = {
header: null,
children: null,
okButtonProps: null,
cancelButtonProps: null,
onOk: () => {},
onCancel: () => {},
};

View File

@@ -0,0 +1,37 @@
@import "~antd/lib/modal/style/index"; // for ant @vars
.input-popover-content {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import Icon from "antd/lib/icon"; import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
export default class InputWithCopy extends React.Component { export default class InputWithCopy extends React.Component {
@@ -42,7 +42,7 @@ export default class InputWithCopy extends React.Component {
render() { render() {
const copyButton = ( const copyButton = (
<Tooltip title={this.state.copied || "Copy"}> <Tooltip title={this.state.copied || "Copy"}>
<Icon type="copy" style={{ cursor: "pointer" }} onClick={this.copy} /> <CopyOutlinedIcon style={{ cursor: "pointer" }} onClick={this.copy} />
</Tooltip> </Tooltip>
); );

View File

@@ -0,0 +1,26 @@
import React from "react";
import Button from "antd/lib/button";
function DefaultLinkComponent(props) {
return <a {...props} />; // eslint-disable-line jsx-a11y/anchor-has-content
}
function Link(props) {
return <Link.Component {...props} />;
}
Link.Component = DefaultLinkComponent;
function DefaultButtonLinkComponent(props) {
return <Button role="button" {...props} />;
}
function ButtonLink(props) {
return <ButtonLink.Component {...props} />;
}
ButtonLink.Component = DefaultButtonLinkComponent;
Link.Button = ButtonLink;
export default Link;

View File

@@ -7,7 +7,7 @@ export default function NoTaggedObjectsFound({ objectType, tags }) {
return ( return (
<BigMessage icon="fa-tags"> <BigMessage icon="fa-tags">
No {objectType} found tagged with&nbsp; No {objectType} found tagged with&nbsp;
<TagsControl className="inline-tags-control" tags={Array.from(tags)} />. <TagsControl className="inline-tags-control" tags={Array.from(tags)} tagSeparator={"+"} />.
</BigMessage> </BigMessage>
); );
} }

View File

@@ -1,16 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
export default function PageHeader({ title }) {
return (
<div className="page-header-wrapper row p-l-15 p-r-15 m-b-10 m-l-0 m-r-0">
<div className="col-sm-9 p-l-0 p-r-0">
<h3>{title}</h3>
</div>
</div>
);
}
PageHeader.propTypes = {
title: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,23 @@
import React from "react";
import PropTypes from "prop-types";
import "./index.less";
export default function PageHeader({ title, actions }) {
return (
<div className="page-header-wrapper">
<h3>{title}</h3>
{actions && <div className="page-header-actions">{actions}</div>}
</div>
);
}
PageHeader.propTypes = {
title: PropTypes.string,
actions: PropTypes.node,
};
PageHeader.defaultProps = {
title: "",
actions: null,
};

View File

@@ -0,0 +1,20 @@
.page-header-wrapper {
margin: 15px 0 10px 0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: stretch;
h3 {
margin: 0;
line-height: 1.3;
font-weight: 500;
flex: 1 1 auto;
}
.page-header-actions {
flex: 0 0 auto;
padding: 0 0 0 15px;
}
}

View File

@@ -2,24 +2,38 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Pagination from "antd/lib/pagination"; import Pagination from "antd/lib/pagination";
export default function Paginator({ page, itemsPerPage, totalCount, onChange }) { const MIN_ITEMS_PER_PAGE = 5;
if (totalCount <= itemsPerPage) {
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) {
if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) {
return null; return null;
} }
return ( return (
<div className="paginator-container"> <div className="paginator-container">
<Pagination defaultCurrent={page} defaultPageSize={itemsPerPage} total={totalCount} onChange={onChange} /> <Pagination
showSizeChanger={showPageSizeSelect}
pageSizeOptions={["5", "10", "20", "50", "100"]}
onShowSizeChange={(_, size) => onPageSizeChange(size)}
defaultCurrent={page}
pageSize={pageSize}
total={totalCount}
onChange={onChange}
/>
</div> </div>
); );
} }
Paginator.propTypes = { Paginator.propTypes = {
page: PropTypes.number.isRequired, page: PropTypes.number.isRequired,
itemsPerPage: PropTypes.number.isRequired, showPageSizeSelect: PropTypes.bool,
pageSize: PropTypes.number.isRequired,
totalCount: PropTypes.number.isRequired, totalCount: PropTypes.number.isRequired,
onPageSizeChange: PropTypes.func,
onChange: PropTypes.func, onChange: PropTypes.func,
}; };
Paginator.defaultProps = { Paginator.defaultProps = {
showPageSizeSelect: false,
onChange: () => {}, onChange: () => {},
onPageSizeChange: () => {},
}; };

View File

@@ -8,7 +8,6 @@ import Select from "antd/lib/select";
import Table from "antd/lib/table"; import Table from "antd/lib/table";
import Popover from "antd/lib/popover"; import Popover from "antd/lib/popover";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Icon from "antd/lib/icon";
import Tag from "antd/lib/tag"; import Tag from "antd/lib/tag";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import Radio from "antd/lib/radio"; import Radio from "antd/lib/radio";
@@ -18,11 +17,15 @@ import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget"; import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters"; import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import InputPopover from "@/components/InputPopover";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
import "./ParameterMappingInput.less"; import "./ParameterMappingInput.less";
const { Option } = Select;
export const MappingType = { export const MappingType = {
DashboardAddNew: "dashboard-add-new", DashboardAddNew: "dashboard-add-new",
DashboardMapToExisting: "dashboard-map-to-existing", DashboardMapToExisting: "dashboard-map-to-existing",
@@ -181,7 +184,7 @@ export class ParameterMappingInput extends React.Component {
Existing dashboard parameter{" "} Existing dashboard parameter{" "}
{noExisting ? ( {noExisting ? (
<Tooltip title="There are no dashboard parameters corresponding to this data type"> <Tooltip title="There are no dashboard parameters corresponding to this data type">
<Icon type="question-circle" theme="filled" /> <QuestionCircleFilledIcon />
</Tooltip> </Tooltip>
) : null} ) : null}
</Radio> </Radio>
@@ -204,19 +207,9 @@ export class ParameterMappingInput extends React.Component {
renderDashboardMapToExisting() { renderDashboardMapToExisting() {
const { mapping, existingParamNames } = this.props; const { mapping, existingParamNames } = this.props;
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
return ( return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />;
<Select
value={mapping.mapTo}
onChange={mapTo => this.updateParamMapping({ mapTo })}
dropdownMatchSelectWidth={false}>
{map(existingParamNames, name => (
<Option value={name} key={name}>
{name}
</Option>
))}
</Select>
);
} }
renderStaticValue() { renderStaticValue() {
@@ -321,43 +314,34 @@ class MappingEditor extends React.Component {
this.setState({ visible: false }); this.setState({ visible: false });
}; };
renderContent() { render() {
const { mapping, inputError } = this.state; const { visible, mapping, inputError } = this.state;
return ( return (
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover"> <InputPopover
<header> placement="left"
trigger="click"
header={
<>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" /> Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</header> </>
}
content={
<ParameterMappingInput <ParameterMappingInput
mapping={mapping} mapping={mapping}
existingParamNames={this.props.existingParamNames} existingParamNames={this.props.existingParamNames}
onChange={this.onChange} onChange={this.onChange}
inputError={inputError} inputError={inputError}
/> />
<footer>
<Button onClick={this.hide}>Cancel</Button>
<Button onClick={this.save} disabled={!!inputError} type="primary">
OK
</Button>
</footer>
</div>
);
} }
onOk={this.save}
render() { onCancel={this.hide}
const { visible, mapping } = this.state; okButtonProps={{ disabled: !!inputError }}
return (
<Popover
placement="left"
trigger="click"
content={this.renderContent()}
visible={visible} visible={visible}
onVisibleChange={this.onVisibleChange}> onVisibleChange={this.onVisibleChange}>
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}> <Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
<Icon type="edit" /> <EditOutlinedIcon />
</Button> </Button>
</Popover> </InputPopover>
); );
} }
} }
@@ -434,10 +418,10 @@ class TitleEditor extends React.Component {
autoFocus autoFocus
/> />
<Button size="small" type="dashed" onClick={this.hide}> <Button size="small" type="dashed" onClick={this.hide}>
<Icon type="close" /> <CloseOutlinedIcon />
</Button> </Button>
<Button size="small" type="dashed" onClick={this.save}> <Button size="small" type="dashed" onClick={this.save}>
<Icon type="check" /> <CheckOutlinedIcon />
</Button> </Button>
</div> </div>
); );
@@ -460,7 +444,7 @@ class TitleEditor extends React.Component {
visible={this.state.showPopup} visible={this.state.showPopup}
onVisibleChange={this.onPopupVisibleChange}> onVisibleChange={this.onPopupVisibleChange}>
<Button size="small" type="dashed"> <Button size="small" type="dashed">
<Icon type="edit" /> <EditOutlinedIcon />
</Button> </Button>
</Popover> </Popover>
); );

View File

@@ -1,4 +1,4 @@
@import '~antd/lib/modal/style/index'; // for ant @vars @import "~antd/lib/modal/style/index"; // for ant @vars
.parameters-mapping-list { .parameters-mapping-list {
.keyword { .keyword {
@@ -22,48 +22,13 @@
} }
} }
.parameter-mapping-editor {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}
.parameter-mapping-title { .parameter-mapping-title {
.text { .text {
margin-right: 3px; margin-right: 3px;
} }
&.disabled, .fa { &.disabled,
.fa {
color: #a4a4a4; color: #a4a4a4;
} }

View File

@@ -1,7 +1,7 @@
import { isEqual } from "lodash"; import { isEqual, isEmpty, map } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Select from "antd/lib/select"; import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number"; import InputNumber from "antd/lib/input-number";
import DateParameter from "@/components/dynamic-parameters/DateParameter"; import DateParameter from "@/components/dynamic-parameters/DateParameter";
@@ -10,8 +10,6 @@ import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less"; import "./ParameterValueInput.less";
const { Option } = Select;
const multipleValuesProps = { const multipleValuesProps = {
maxTagCount: 3, maxTagCount: 3,
maxTagTextLength: 10, maxTagTextLength: 10,
@@ -98,26 +96,20 @@ class ParameterValueInput extends React.Component {
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 (
<Select <SelectWithVirtualScroll
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children" optionFilterProp="children"
disabled={enumOptionsArray.length === 0}
value={normalize(value)} value={normalize(value)}
onChange={this.onSelect} onChange={this.onSelect}
dropdownMatchSelectWidth={false} options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
showSearch showSearch
showArrow showArrow
style={{ minWidth: 60 }} notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
notFoundContent={null} {...multipleValuesProps}
{...multipleValuesProps}> />
{enumOptionsArray.map(option => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
); );
} }

View File

@@ -1,4 +1,4 @@
@import '~antd/lib/input-number/style/index'; // for ant @vars @import "~antd/lib/input-number/style/index"; // for ant @vars
@input-dirty: #fffce1; @input-dirty: #fffce1;
@@ -17,10 +17,11 @@
} }
&[data-dirty] { &[data-dirty] {
.@{ant-prefix}-input, // covers also ant date component .@{ant-prefix}-input,
.@{ant-prefix}-input-number, .@{ant-prefix}-input-number,
.@{ant-prefix}-select-selection { .@{ant-prefix}-select-selector,
background-color: @input-dirty; .@{ant-prefix}-picker {
background-color: @input-dirty !important;
} }
} }
} }

View File

@@ -1,13 +1,12 @@
import { size, filter, forEach, extend } from "lodash"; import { size, filter, forEach, extend } 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 "@/components/sortable"; import { SortableContainer, SortableElement, DragHandle } from "@redash/viz/lib/components/sortable";
import location from "@/services/location"; import location from "@/services/location";
import { Parameter, createParameter } from "@/services/parameters"; import { Parameter, createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton"; import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput"; import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog"; import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/lib/utils";
import "./Parameters.less"; import "./Parameters.less";
@@ -106,16 +105,14 @@ export default class Parameters extends React.Component {
showParameterSettings = (parameter, index) => { showParameterSettings = (parameter, index) => {
const { onParametersEdit } = this.props; const { onParametersEdit } = this.props;
EditParameterSettingsDialog.showModal({ parameter }) EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => {
.result.then(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);
onParametersEdit(); onParametersEdit();
return { parameters }; return { parameters };
}); });
}) });
.catch(() => {}); // ignore dismiss
}; };
renderParameter(param, index) { renderParameter(param, index) {
@@ -123,7 +120,7 @@ export default class Parameters extends React.Component {
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">
<label>{param.title || toHuman(param.name)}</label> <label>{param.getTitle()}</label>
{editable && ( {editable && (
<button <button
className="btn btn-default btn-xs m-l-5" className="btn btn-default btn-xs m-l-5"

View File

@@ -1,4 +1,4 @@
@import '../assets/less/ant'; @import "../assets/less/ant";
.parameter-block { .parameter-block {
display: inline-block; display: inline-block;
@@ -20,6 +20,7 @@
} }
&.parameter-dragged { &.parameter-dragged {
z-index: 2;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
} }
} }
@@ -60,7 +61,7 @@
bottom: -36px; bottom: -36px;
left: -15px; left: -15px;
border-radius: 2px; border-radius: 2px;
z-index: 1; z-index: 2;
transition: opacity 150ms ease-out; transition: opacity 150ms ease-out;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
background-color: #ffffff; background-color: #ffffff;
@@ -88,7 +89,9 @@
height: 27px; height: 27px;
} }
&:hover, &:focus, &:active { &:hover,
&:focus,
&:active {
background-color: #eef7fe; background-color: #eef7fe;
} }

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import classNames from "classnames";
import Link from "@/components/Link";
// PreviewCard // PreviewCard
@@ -42,7 +43,7 @@ PreviewCard.defaultProps = {
// UserPreviewCard // UserPreviewCard
export function UserPreviewCard({ user, withLink, children, ...props }) { export function UserPreviewCard({ user, withLink, children, ...props }) {
const title = withLink ? <a href={"users/" + user.id}>{user.name}</a> : user.name; const title = withLink ? <Link href={"users/" + user.id}>{user.name}</Link> : user.name;
return ( return (
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}> <PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
{children} {children}
@@ -68,8 +69,8 @@ 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 ? <a href={"data_sources/" + dataSource.id}>{dataSource.name}</a> : 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}>
{children} {children}

View File

@@ -1,9 +1,18 @@
import { find, isArray, map, intersection, isEqual } from "lodash"; import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Select from "antd/lib/select"; import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
const { Option } = Select; const SEARCH_DEBOUNCE_TIME = 300;
function filterValuesThatAreNotInOptions(value, options) {
if (isArray(value)) {
const optionValues = map(options, option => option.value);
return intersection(value, optionValues);
}
const found = find(options, option => option.value === value) !== undefined;
return found ? value : get(first(options), "value");
}
export default class QueryBasedParameterInput extends React.Component { export default class QueryBasedParameterInput extends React.Component {
static propTypes = { static propTypes = {
@@ -30,6 +39,7 @@ export default class QueryBasedParameterInput extends React.Component {
options: [], options: [],
value: null, value: null,
loading: false, loading: false,
currentSearchTerm: null,
}; };
} }
@@ -38,9 +48,10 @@ export default class QueryBasedParameterInput extends React.Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.queryId !== prevProps.queryId) { if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
this._loadOptions(this.props.queryId); this._loadOptions(this.props.queryId);
} }
if (this.props.value !== prevProps.value) { if (this.props.value !== prevProps.value) {
this.setValue(this.props.value); this.setValue(this.props.value);
} }
@@ -48,26 +59,26 @@ export default class QueryBasedParameterInput extends React.Component {
setValue(value) { setValue(value) {
const { options } = this.state; const { options } = this.state;
if (this.props.mode === "multiple") { const { mode, parameter } = this.props;
value = isArray(value) ? value : [value];
const optionValues = map(options, option => option.value); if (mode === "multiple") {
const validValues = intersection(value, optionValues); if (isNil(value)) {
this.setState({ value: validValues }); value = [];
return validValues;
} }
const found = find(options, option => option.value === this.props.value) !== undefined;
value = found ? value : options[0].value; value = isArray(value) ? value : [value];
}
// parameters with search don't have options available, so we trust what we get
if (!parameter.searchFunction) {
value = filterValuesThatAreNotInOptions(value, options);
}
this.setState({ value }); this.setState({ value });
return value; return value;
} }
async _loadOptions(queryId) { updateOptions(options) {
if (queryId && queryId !== this.state.queryId) {
this.setState({ loading: true });
const options = await this.props.parameter.loadDropdownValues();
// stale queryId check
if (this.props.queryId === queryId) {
this.setState({ options, loading: false }, () => { this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value); const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) { if (!isEqual(updatedValue, this.props.value)) {
@@ -75,33 +86,59 @@ export default class QueryBasedParameterInput extends React.Component {
} }
}); });
} }
async _loadOptions(queryId) {
if (queryId && queryId !== this.state.queryId) {
this.setState({ loading: true });
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
// stale queryId check
if (this.props.queryId === queryId) {
this.updateOptions(options);
}
} }
} }
searchFunction = debounce(searchTerm => {
const { parameter } = this.props;
if (parameter.searchFunction && trim(searchTerm)) {
this.setState({ loading: true, currentSearchTerm: searchTerm });
parameter.searchFunction(searchTerm).then(options => {
if (this.state.currentSearchTerm === searchTerm) {
this.updateOptions(options);
}
});
}
}, SEARCH_DEBOUNCE_TIME);
render() { render() {
const { className, value, mode, onSelect, ...otherProps } = this.props; const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
const { loading, options } = this.state; const { loading, options } = this.state;
const selectProps = { ...otherProps };
if (parameter.searchColumn) {
selectProps.filterOption = false;
selectProps.onSearch = this.searchFunction;
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
selectProps.notFoundContent = null;
selectProps.labelInValue = true;
}
return ( return (
<span> <span>
<Select <SelectWithVirtualScroll
className={className} className={className}
disabled={loading || options.length === 0} disabled={!parameter.searchFunction && loading}
loading={loading} loading={loading}
mode={mode} mode={mode}
value={this.state.value} value={this.state.value || undefined}
onChange={onSelect} onChange={onSelect}
dropdownMatchSelectWidth={false} options={options}
optionFilterProp="children" optionFilterProp="children"
showSearch showSearch
showArrow showArrow
notFoundContent={null} notFoundContent={isEmpty(options) ? "No options available" : null}
{...otherProps}> {...selectProps}
{options.map(option => ( />
<Option value={option.value} key={option.value}>
{option.name}
</Option>
))}
</Select>
</span> </span>
); );
} }

View File

@@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { VisualizationType } from "@/visualizations/prop-types"; import { VisualizationType } from "@redash/viz/lib";
import VisualizationName from "@/visualizations/components/VisualizationName"; import Link from "@/components/Link";
import VisualizationName from "@/components/visualizations/VisualizationName";
import "./QueryLink.less"; import "./QueryLink.less";
@@ -21,9 +22,9 @@ function QueryLink({ query, visualization, readOnly }) {
}; };
return ( return (
<a href={readOnly ? null : getUrl()} className="query-link"> <Link href={readOnly ? null : getUrl()} className="query-link">
<VisualizationName visualization={visualization} /> <span>{query.name}</span> <VisualizationName visualization={visualization} /> <span>{query.name}</span>
</a> </Link>
); );
} }

View File

@@ -11,6 +11,10 @@ import useSearchResults from "@/lib/hooks/useSearchResults";
const { Option } = Select; const { Option } = Select;
function search(term) { function search(term) {
if (term === null) {
return Promise.resolve(null);
}
// get recent // get recent
if (!term) { if (!term) {
return Query.recent().then(results => results.filter(item => !item.is_draft)); // filter out draft return Query.recent().then(results => results.filter(item => !item.is_draft)); // filter out draft

View File

@@ -1,5 +1,5 @@
import { filter, debounce, find, isEmpty, size } from "lodash"; import { filter, find, isEmpty, size } from "lodash";
import React from "react"; import React, { useState, useCallback, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import classNames from "classnames";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
@@ -8,12 +8,155 @@ import List from "antd/lib/list";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import BigMessage from "@/components/BigMessage"; import BigMessage from "@/components/BigMessage";
import LoadingState from "@/components/items-list/components/LoadingState"; import LoadingState from "@/components/items-list/components/LoadingState";
import notification from "@/services/notification"; import notification from "@/services/notification";
import useSearchResults from "@/lib/hooks/useSearchResults";
class SelectItemsDialog extends React.Component { function ItemsList({ items, renderItem, onItemClick }) {
static propTypes = { const renderListItem = useCallback(
item => {
const { content, className, isDisabled } = renderItem(item);
return (
<List.Item
className={classNames("p-l-10", "p-r-10", { clickable: !isDisabled, disabled: isDisabled }, className)}
onClick={isDisabled ? null : () => onItemClick(item)}>
{content}
</List.Item>
);
},
[renderItem, onItemClick]
);
return <List size="small" dataSource={items} renderItem={renderListItem} />;
}
ItemsList.propTypes = {
items: PropTypes.array,
renderItem: PropTypes.func,
onItemClick: PropTypes.func,
};
ItemsList.defaultProps = {
items: [],
renderItem: () => {},
onItemClick: () => {},
};
function SelectItemsDialog({
dialog,
dialogTitle,
inputPlaceholder,
itemKey,
renderItem,
renderStagedItem,
searchItems,
selectedItemsTitle,
width,
showCount,
extraFooterContent,
}) {
const [selectedItems, setSelectedItems] = useState([]);
const [search, items, isLoading] = useSearchResults(searchItems, { initialResults: [] });
const hasResults = items.length > 0;
useEffect(() => {
search();
}, [search]);
const isItemSelected = useCallback(
item => {
const key = itemKey(item);
return !!find(selectedItems, i => itemKey(i) === key);
},
[selectedItems, itemKey]
);
const toggleItem = useCallback(
item => {
if (isItemSelected(item)) {
const key = itemKey(item);
setSelectedItems(filter(selectedItems, i => itemKey(i) !== key));
} else {
setSelectedItems([...selectedItems, item]);
}
},
[selectedItems, itemKey, isItemSelected]
);
const save = useCallback(() => {
dialog.close(selectedItems).catch(error => {
if (error) {
notification.error("Failed to save some of selected items.");
}
});
}, [dialog, selectedItems]);
return (
<Modal
{...dialog.props}
className="select-items-dialog"
width={width}
title={dialogTitle}
footer={
<div className="d-flex align-items-center">
<span className="flex-fill m-r-5" style={{ textAlign: "left", color: "rgba(0, 0, 0, 0.5)" }}>
{extraFooterContent}
</span>
<Button {...dialog.props.cancelButtonProps} onClick={dialog.dismiss}>
Cancel
</Button>
<Button
{...dialog.props.okButtonProps}
onClick={save}
disabled={selectedItems.length === 0 || dialog.props.okButtonProps.disabled}
type="primary">
Save
{showCount && !isEmpty(selectedItems) ? ` (${size(selectedItems)})` : null}
</Button>
</div>
}>
<div className="d-flex align-items-center m-b-10">
<div className="flex-fill">
<Input.Search onChange={event => search(event.target.value)} placeholder={inputPlaceholder} autoFocus />
</div>
{renderStagedItem && (
<div className="w-50 m-l-20">
<h5 className="m-0">{selectedItemsTitle}</h5>
</div>
)}
</div>
<div className="d-flex align-items-stretch" style={{ minHeight: "30vh", maxHeight: "50vh" }}>
<div className="flex-fill scrollbox">
{isLoading && <LoadingState className="" />}
{!isLoading && !hasResults && (
<BigMessage icon="fa-search" message="No items match your search." className="" />
)}
{!isLoading && hasResults && (
<ItemsList
items={items}
renderItem={item => renderItem(item, { isSelected: isItemSelected(item) })}
onItemClick={toggleItem}
/>
)}
</div>
{renderStagedItem && (
<div className="w-50 m-l-20 scrollbox">
{selectedItems.length > 0 && (
<ItemsList
items={selectedItems}
renderItem={item => renderStagedItem(item, { isSelected: true })}
onItemClick={toggleItem}
/>
)}
</div>
)}
</div>
</Modal>
);
}
SelectItemsDialog.propTypes = {
dialog: DialogPropType.isRequired, dialog: DialogPropType.isRequired,
dialogTitle: PropTypes.string, dialogTitle: PropTypes.string,
inputPlaceholder: PropTypes.string, inputPlaceholder: PropTypes.string,
@@ -29,170 +172,21 @@ class SelectItemsDialog extends React.Component {
renderItem: PropTypes.func, renderItem: PropTypes.func,
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used // right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
renderStagedItem: PropTypes.func, renderStagedItem: PropTypes.func,
save: PropTypes.func, // (selectedItems[]) => Promise<any>
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
extraFooterContent: PropTypes.node, extraFooterContent: PropTypes.node,
showCount: PropTypes.bool, showCount: PropTypes.bool,
}; };
static defaultProps = { SelectItemsDialog.defaultProps = {
dialogTitle: "Add Items", dialogTitle: "Add Items",
inputPlaceholder: "Search...", inputPlaceholder: "Search...",
selectedItemsTitle: "Selected items", selectedItemsTitle: "Selected items",
itemKey: item => item.id, itemKey: item => item.id,
renderItem: () => "", renderItem: () => "",
renderStagedItem: null, // hidden by default renderStagedItem: null, // hidden by default
save: items => items,
width: "80%", width: "80%",
extraFooterContent: null, extraFooterContent: null,
showCount: false, showCount: false,
}; };
state = {
searchTerm: "",
loading: false,
items: [],
selected: [],
saveInProgress: false,
};
// eslint-disable-next-line react/sort-comp
loadItems = (searchTerm = "") => {
this.setState({ searchTerm, loading: true }, () => {
this.props
.searchItems(searchTerm)
.then(items => {
// If another search appeared while loading data - just reject this set
if (this.state.searchTerm === searchTerm) {
this.setState({ items, loading: false });
}
})
.catch(() => {
if (this.state.searchTerm === searchTerm) {
this.setState({ items: [], loading: false });
}
});
});
};
search = debounce(this.loadItems, 200);
componentDidMount() {
this.loadItems();
}
isSelected(item) {
const key = this.props.itemKey(item);
return !!find(this.state.selected, i => this.props.itemKey(i) === key);
}
toggleItem(item) {
if (this.isSelected(item)) {
const key = this.props.itemKey(item);
this.setState(({ selected }) => ({
selected: filter(selected, i => this.props.itemKey(i) !== key),
}));
} else {
this.setState(({ selected }) => ({
selected: [...selected, item],
}));
}
}
save() {
this.setState({ saveInProgress: true }, () => {
const selectedItems = this.state.selected;
Promise.resolve(this.props.save(selectedItems))
.then(() => {
this.props.dialog.close(selectedItems);
})
.catch(() => {
this.setState({ saveInProgress: false });
notification.error("Failed to save some of selected items.");
});
});
}
renderItem(item, isStagedList) {
const { renderItem, renderStagedItem } = this.props;
const isSelected = this.isSelected(item);
const render = isStagedList ? renderStagedItem : renderItem;
const { content, className, isDisabled } = render(item, { isSelected });
return (
<List.Item
className={classNames("p-l-10", "p-r-10", { clickable: !isDisabled, disabled: isDisabled }, className)}
onClick={isDisabled ? null : () => this.toggleItem(item)}>
{content}
</List.Item>
);
}
render() {
const { dialog, dialogTitle, inputPlaceholder } = this.props;
const { selectedItemsTitle, renderStagedItem, width, showCount } = this.props;
const { loading, saveInProgress, items, selected } = this.state;
const hasResults = items.length > 0;
return (
<Modal
{...dialog.props}
className="select-items-dialog"
width={width}
title={dialogTitle}
footer={
<div className="d-flex align-items-center">
<span className="flex-fill m-r-5" style={{ textAlign: "left", color: "rgba(0, 0, 0, 0.5)" }}>
{this.props.extraFooterContent}
</span>
<Button onClick={dialog.dismiss}>Cancel</Button>
<Button
onClick={() => this.save()}
loading={saveInProgress}
disabled={selected.length === 0}
type="primary">
Save
{showCount && !isEmpty(selected) ? ` (${size(selected)})` : null}
</Button>
</div>
}>
<div className="d-flex align-items-center m-b-10">
<div className="flex-fill">
<Input.Search
defaultValue={this.state.searchTerm}
onChange={event => this.search(event.target.value)}
placeholder={inputPlaceholder}
autoFocus
/>
</div>
{renderStagedItem && (
<div className="w-50 m-l-20">
<h5 className="m-0">{selectedItemsTitle}</h5>
</div>
)}
</div>
<div className="d-flex align-items-stretch" style={{ minHeight: "30vh", maxHeight: "50vh" }}>
<div className="flex-fill scrollbox">
{loading && <LoadingState className="" />}
{!loading && !hasResults && (
<BigMessage icon="fa-search" message="No items match your search." className="" />
)}
{!loading && hasResults && (
<List size="small" dataSource={items} renderItem={item => this.renderItem(item, false)} />
)}
</div>
{renderStagedItem && (
<div className="w-50 m-l-20 scrollbox">
{selected.length > 0 && (
<List size="small" dataSource={selected} renderItem={item => this.renderItem(item, true)} />
)}
</div>
)}
</div>
</Modal>
);
}
}
export default wrapDialog(SelectItemsDialog); export default wrapDialog(SelectItemsDialog);

View File

@@ -0,0 +1,38 @@
import React, { useMemo } from "react";
import { maxBy } from "lodash";
import AntdSelect, { SelectProps, LabeledValue } from "antd/lib/select";
import { calculateTextWidth } from "@/lib/calculateTextWidth";
const MIN_LEN_FOR_VIRTUAL_SCROLL = 400;
interface VirtualScrollLabeledValue extends LabeledValue {
label: string;
}
interface VirtualScrollSelectProps extends SelectProps<string> {
options: Array<VirtualScrollLabeledValue>;
}
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
const dropdownMatchSelectWidth = useMemo<number | boolean>(() => {
if (options && options.length > MIN_LEN_FOR_VIRTUAL_SCROLL) {
const largestOpt = maxBy(options, "label.length");
if (largestOpt) {
const offset = 40;
const optionText = largestOpt.label;
const width = calculateTextWidth(optionText);
if (width) {
return width + offset;
}
}
return true;
}
return false;
}, [options]);
return <AntdSelect<string> dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />;
}
export default SelectWithVirtualScroll;

View File

@@ -1,13 +1,12 @@
import React from "react"; import React from "react";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import location from "@/services/location"; import location from "@/services/location";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
function wrapSettingsTab(options, WrappedComponent) { function wrapSettingsTab(id, options, WrappedComponent) {
if (options) { settingsMenu.add(id, options);
settingsMenu.add(options);
}
return function SettingsTab(props) { return function SettingsTab(props) {
const activeItem = settingsMenu.getActiveItem(location.path); const activeItem = settingsMenu.getActiveItem(location.path);
@@ -17,13 +16,11 @@ function wrapSettingsTab(options, WrappedComponent) {
<PageHeader title="Settings" /> <PageHeader title="Settings" />
<div className="bg-white tiled"> <div className="bg-white tiled">
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal"> <Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
{settingsMenu.items {settingsMenu.getAvailableItems().map(item => (
.filter(item => item.isAvailable())
.map(item => (
<Menu.Item key={item.title}> <Menu.Item key={item.title}>
<a href={item.path} data-test="SettingsScreenItem"> <Link href={item.path} data-test="SettingsScreenItem">
{item.title} {item.title}
</a> </Link>
</Menu.Item> </Menu.Item>
))} ))}
</Menu> </Menu>

View File

@@ -1,82 +0,0 @@
import { map } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Badge from "antd/lib/badge";
import Menu from "antd/lib/menu";
import getTags from "@/services/getTags";
import "./TagsList.less";
export default class TagsList extends React.Component {
static propTypes = {
tagsUrl: PropTypes.string.isRequired,
onUpdate: PropTypes.func,
};
static defaultProps = {
onUpdate: () => {},
};
constructor(props) {
super(props);
this.state = {
// An array of objects that with the name and count of the tagged items
allTags: [],
// A set of tag names
selectedTags: new Set(),
};
}
componentDidMount() {
getTags(this.props.tagsUrl).then(allTags => {
this.setState({ allTags });
});
}
toggleTag(event, tag) {
const { selectedTags } = this.state;
if (event.shiftKey) {
// toggle tag
if (selectedTags.has(tag)) {
selectedTags.delete(tag);
} else {
selectedTags.add(tag);
}
} else {
// if the tag is the only selected, deselect it, otherwise select only it
if (selectedTags.has(tag) && selectedTags.size === 1) {
selectedTags.clear();
} else {
selectedTags.clear();
selectedTags.add(tag);
}
}
this.forceUpdate();
this.props.onUpdate([...this.state.selectedTags]);
}
render() {
const { allTags, selectedTags } = this.state;
if (allTags.length > 0) {
return (
<div className="m-t-10 tags-list tiled">
<Menu className="invert-stripe-position" mode="inline" selectedKeys={[...selectedTags]}>
{map(allTags, tag => (
<Menu.Item key={tag.name} className="m-0">
<a
className="d-flex align-items-center justify-content-between"
onClick={event => this.toggleTag(event, tag.name)}>
<span className="max-character col-xs-11">{tag.name}</span>
<Badge count={tag.count} />
</a>
</Menu.Item>
))}
</Menu>
</div>
);
}
return null;
}
}

View File

@@ -1,11 +1,42 @@
@import '~@/assets/less/ant'; @import "~@/assets/less/ant";
.tags-list { .tags-list {
.tags-list-title {
margin: 15px 5px 5px 5px;
display: flex;
justify-content: space-between;
align-items: center;
label {
display: block;
white-space: nowrap;
margin: 0;
}
a {
display: block;
white-space: nowrap;
cursor: pointer;
.anticon {
font-size: 75%;
margin-right: 2px;
}
}
}
.ant-badge-count { .ant-badge-count {
background-color: fade(@redash-gray, 10%); background-color: fade(@redash-gray, 10%);
color: fade(@redash-gray, 75%); color: fade(@redash-gray, 75%);
} }
.ant-menu.ant-menu-inline {
border: none;
.ant-menu-item {
width: 100%;
}
.ant-menu-item-selected { .ant-menu-item-selected {
.ant-badge-count { .ant-badge-count {
background-color: @primary-color; background-color: @primary-color;
@@ -13,3 +44,4 @@
} }
} }
} }
}

View File

@@ -0,0 +1,107 @@
import { map, includes, difference } from "lodash";
import React, { useState, useCallback, useEffect } from "react";
import Badge from "antd/lib/badge";
import Menu from "antd/lib/menu";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import getTags from "@/services/getTags";
import "./TagsList.less";
type Tag = {
name: string;
count?: number;
};
type TagsListProps = {
tagsUrl: string;
showUnselectAll: boolean;
onUpdate?: (selectedTags: string[]) => void;
};
function TagsList({ tagsUrl, showUnselectAll = false, onUpdate }: TagsListProps): JSX.Element | null {
const [allTags, setAllTags] = useState<Tag[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
useEffect(() => {
let isCancelled = false;
getTags(tagsUrl).then(tags => {
if (!isCancelled) {
setAllTags(tags);
}
});
return () => {
isCancelled = true;
};
}, [tagsUrl]);
const toggleTag = useCallback(
(event, tag) => {
let newSelectedTags;
if (event.shiftKey) {
// toggle tag
if (includes(selectedTags, tag)) {
newSelectedTags = difference(selectedTags, [tag]);
} else {
newSelectedTags = [...selectedTags, tag];
}
} else {
// if the tag is the only selected, deselect it, otherwise select only it
if (includes(selectedTags, tag) && selectedTags.length === 1) {
newSelectedTags = [];
} else {
newSelectedTags = [tag];
}
}
setSelectedTags(newSelectedTags);
if (onUpdate) {
onUpdate([...newSelectedTags]);
}
},
[selectedTags, onUpdate]
);
const unselectAll = useCallback(() => {
setSelectedTags([]);
if (onUpdate) {
onUpdate([]);
}
}, [onUpdate]);
if (allTags.length === 0) {
return null;
}
return (
<div className="tags-list">
<div className="tags-list-title">
<label>Tags</label>
{showUnselectAll && selectedTags.length > 0 && (
<a onClick={unselectAll}>
<CloseOutlinedIcon />
clear selection
</a>
)}
</div>
<div className="tiled">
<Menu className="invert-stripe-position" mode="inline" selectedKeys={selectedTags}>
{map(allTags, tag => (
<Menu.Item key={tag.name} className="m-0">
<a
className="d-flex align-items-center justify-content-between"
onClick={event => toggleTag(event, tag.name)}>
<span className="max-character col-xs-11">{tag.name}</span>
<Badge count={tag.count} />
</a>
</Menu.Item>
))}
</Menu>
</div>
</div>
);
}
export default TagsList;

View File

@@ -11,7 +11,7 @@ function toMoment(value) {
return value && value.isValid() ? value : null; return value && value.isValid() ? value : null;
} }
export default function TimeAgo({ date, placeholder, autoUpdate }) { export default function TimeAgo({ date, placeholder, autoUpdate, variation }) {
const startDate = toMoment(date); const startDate = toMoment(date);
const [value, setValue] = useState(null); const [value, setValue] = useState(null);
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]); const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
@@ -28,6 +28,13 @@ export default function TimeAgo({ date, placeholder, autoUpdate }) {
} }
}, [autoUpdate, startDate, placeholder]); }, [autoUpdate, startDate, placeholder]);
if (variation === "timeAgoInTooltip") {
return (
<Tooltip title={value}>
<span data-test="TimeAgo">{title}</span>
</Tooltip>
);
}
return ( return (
<Tooltip title={title}> <Tooltip title={title}>
<span data-test="TimeAgo">{value}</span> <span data-test="TimeAgo">{value}</span>
@@ -39,6 +46,7 @@ TimeAgo.propTypes = {
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]), date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
placeholder: PropTypes.string, placeholder: PropTypes.string,
autoUpdate: PropTypes.bool, autoUpdate: PropTypes.bool,
variation: PropTypes.oneOf(["timeAgoInTooltip"]),
}; };
TimeAgo.defaultProps = { TimeAgo.defaultProps = {

View File

@@ -0,0 +1,32 @@
import { map } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Tag from "antd/lib/tag";
import Link from "@/components/Link";
import "./UserGroups.less";
export default function UserGroups({ groups, linkGroups, ...props }) {
return (
<div className="user-groups" {...props}>
{map(groups, group => (
<Tag key={group.id}>{linkGroups ? <Link href={`groups/${group.id}`}>{group.name}</Link> : group.name}</Tag>
))}
</div>
);
}
UserGroups.propTypes = {
groups: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
name: PropTypes.string,
})
),
linkGroups: PropTypes.bool,
};
UserGroups.defaultProps = {
groups: [],
linkGroups: true,
};

View File

@@ -0,0 +1,7 @@
.user-groups {
margin: -5px 0 0 -5px;
.ant-tag {
margin: 5px 0 0 5px;
}
}

View File

@@ -1,27 +1,30 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Tabs from "antd/lib/tabs"; import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import "./layout.less"; import "./layout.less";
export default function Layout({ activeTab, children }) { export default function Layout({ activeTab, children }) {
return ( return (
<div className="container admin-page-layout"> <div className="admin-page-layout">
<div className="container">
<PageHeader title="Admin" /> <PageHeader title="Admin" />
<div className="bg-white tiled"> <div className="bg-white tiled">
<Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false} tabBarGutter={0}> <Menu selectedKeys={[activeTab]} selectable={false} mode="horizontal">
<Tabs.TabPane key="system_status" tab={<a href="admin/status">System Status</a>}> <Menu.Item key="system_status">
{activeTab === "system_status" ? children : null} <Link href="admin/status">System Status</Link>
</Tabs.TabPane> </Menu.Item>
<Tabs.TabPane key="jobs" tab={<a href="admin/queries/jobs">RQ Status</a>}> <Menu.Item key="jobs">
{activeTab === "jobs" ? children : null} <Link href="admin/queries/jobs">RQ Status</Link>
</Tabs.TabPane> </Menu.Item>
<Tabs.TabPane key="outdated_queries" tab={<a href="admin/queries/outdated">Outdated Queries</a>}> <Menu.Item key="outdated_queries">
{activeTab === "outdated_queries" ? children : null} <Link href="admin/queries/outdated">Outdated Queries</Link>
</Tabs.TabPane> </Menu.Item>
</Tabs> </Menu>
{children}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,17 +1,5 @@
.admin-page-layout { .admin-page-layout {
&-tabs.ant-tabs { .ant-table {
> .ant-tabs-bar { overflow-x: auto;
margin: 0;
.ant-tabs-tab {
padding: 0;
a {
display: inline-block;
padding: 12px 16px;
color: inherit;
}
}
}
} }
} }

View File

@@ -1,83 +0,0 @@
import Input from "antd/lib/input";
import { includes, isEmpty } from "lodash";
import PropTypes from "prop-types";
import React from "react";
import EmptyState from "@/components/items-list/components/EmptyState";
import "./CardsList.less";
const { Search } = Input;
export default class CardsList extends React.Component {
static propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
imgSrc: PropTypes.string.isRequired,
onClick: PropTypes.func,
href: PropTypes.string,
})
),
showSearch: PropTypes.bool,
};
static defaultProps = {
items: [],
showSearch: false,
};
state = {
searchText: "",
};
constructor(props) {
super(props);
this.items = [];
let itemId = 1;
props.items.forEach(item => {
this.items.push({ id: itemId, ...item });
itemId += 1;
});
}
// eslint-disable-next-line class-methods-use-this
renderListItem(item) {
return (
<a key={`card${item.id}`} className="visual-card" onClick={item.onClick} href={item.href}>
<img alt={item.title} src={item.imgSrc} />
<h3>{item.title}</h3>
</a>
);
}
render() {
const { showSearch } = this.props;
const { searchText } = this.state;
const filteredItems = this.items.filter(
item => isEmpty(searchText) || includes(item.title.toLowerCase(), searchText.toLowerCase())
);
return (
<div data-test="CardsList">
{showSearch && (
<div className="row p-10">
<div className="col-md-4 col-md-offset-4">
<Search placeholder="Search..." onChange={e => this.setState({ searchText: e.target.value })} autoFocus />
</div>
</div>
)}
{isEmpty(filteredItems) ? (
<EmptyState className="" />
) : (
<div className="row">
<div className="col-lg-12 d-inline-flex flex-wrap visual-card-list">
{filteredItems.map(item => this.renderListItem(item))}
</div>
</div>
)}
</div>
);
}
}

View File

@@ -0,0 +1,80 @@
import { includes, isEmpty } from "lodash";
import PropTypes from "prop-types";
import React, { useState } from "react";
import Input from "antd/lib/input";
import Link from "@/components/Link";
import EmptyState from "@/components/items-list/components/EmptyState";
import "./CardsList.less";
export interface CardsListItem {
title: string;
imgSrc: string;
onClick?: () => void;
href?: string;
}
export interface CardsListProps {
items?: CardsListItem[];
showSearch?: boolean;
}
interface ListItemProps {
item: CardsListItem;
keySuffix: string;
}
function ListItem({ item, keySuffix }: ListItemProps) {
return (
<Link key={`card${keySuffix}`} className="visual-card" onClick={item.onClick} href={item.href}>
<img alt={item.title} src={item.imgSrc} />
<h3>{item.title}</h3>
</Link>
);
}
export default function CardsList({ items = [], showSearch = false }: CardsListProps) {
const [searchText, setSearchText] = useState("");
const filteredItems = items.filter(
item => isEmpty(searchText) || includes(item.title.toLowerCase(), searchText.toLowerCase())
);
return (
<div data-test="CardsList">
{showSearch && (
<div className="row p-10">
<div className="col-md-4 col-md-offset-4">
<Input.Search
placeholder="Search..."
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchText(e.target.value)}
autoFocus
/>
</div>
</div>
)}
{isEmpty(filteredItems) ? (
<EmptyState className="" />
) : (
<div className="row">
<div className="col-lg-12 d-inline-flex flex-wrap visual-card-list">
{filteredItems.map((item: CardsListItem, index: number) => (
<ListItem key={index} item={item} keySuffix={index.toString()} />
))}
</div>
</div>
)}
</div>
);
}
CardsList.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
imgSrc: PropTypes.string.isRequired,
onClick: PropTypes.func,
href: PropTypes.string,
})
),
showSearch: PropTypes.bool,
};

View File

@@ -1,47 +1,86 @@
import { each, values, map, includes, first } from "lodash"; import { map, includes, groupBy, first, find } from "lodash";
import React from "react"; import React, { useState, useMemo, useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Select from "antd/lib/select"; import Select from "antd/lib/select";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { MappingType, ParameterMappingListInput } from "@/components/ParameterMappingInput"; import { MappingType, ParameterMappingListInput } from "@/components/ParameterMappingInput";
import QuerySelector from "@/components/QuerySelector"; import QuerySelector from "@/components/QuerySelector";
import notification from "@/services/notification"; import notification from "@/services/notification";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
const { Option, OptGroup } = Select; function VisualizationSelect({ query, visualization, onChange }) {
const visualizationGroups = useMemo(() => {
return query ? groupBy(query.visualizations, "type") : {};
}, [query]);
class AddWidgetDialog extends React.Component { const handleChange = useCallback(
static propTypes = { visualizationId => {
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types const selectedVisualization = query ? find(query.visualizations, { id: visualizationId }) : null;
dialog: DialogPropType.isRequired, onChange(selectedVisualization || null);
onConfirm: PropTypes.func.isRequired, },
[query, onChange]
);
if (!query) {
return null;
}
return (
<div>
<div className="form-group">
<label htmlFor="choose-visualization">Choose Visualization</label>
<Select
id="choose-visualization"
className="w-100"
value={visualization ? visualization.id : undefined}
onChange={handleChange}>
{map(visualizationGroups, (visualizations, groupKey) => (
<Select.OptGroup key={groupKey} label={groupKey}>
{map(visualizations, visualization => (
<Select.Option key={`${visualization.id}`} value={visualization.id}>
{visualization.name}
</Select.Option>
))}
</Select.OptGroup>
))}
</Select>
</div>
</div>
);
}
VisualizationSelect.propTypes = {
query: PropTypes.object,
visualization: PropTypes.object,
onChange: PropTypes.func,
}; };
state = { VisualizationSelect.defaultProps = {
saveInProgress: false, query: null,
selectedQuery: null, visualization: null,
selectedVis: null, onChange: () => {},
parameterMappings: [],
}; };
selectQuery(selectedQuery) { function AddWidgetDialog({ dialog, dashboard }) {
const [selectedQuery, setSelectedQuery] = useState(null);
const [selectedVisualization, setSelectedVisualization] = useState(null);
const [parameterMappings, setParameterMappings] = useState([]);
const selectQuery = useCallback(
queryId => {
// Clear previously selected query (if any) // Clear previously selected query (if any)
this.setState({ setSelectedQuery(null);
selectedQuery: null, setSelectedVisualization(null);
selectedVis: null, setParameterMappings([]);
parameterMappings: [],
});
if (selectedQuery) { if (queryId) {
Query.get({ id: selectedQuery.id }).then(query => { Query.get({ id: queryId }).then(query => {
if (query) { if (query) {
const existingParamNames = map(this.props.dashboard.getParametersDefs(), param => param.name); const existingParamNames = map(dashboard.getParametersDefs(), param => param.name);
this.setState({ setSelectedQuery(query);
selectedQuery: query, setParameterMappings(
parameterMappings: map(query.getParametersDefs(), param => ({ map(query.getParametersDefs(), param => ({
name: param.name, name: param.name,
type: includes(existingParamNames, param.name) type: includes(existingParamNames, param.name)
? MappingType.DashboardMapToExisting ? MappingType.DashboardMapToExisting
@@ -50,115 +89,68 @@ class AddWidgetDialog extends React.Component {
value: param.normalizedValue, value: param.normalizedValue,
title: "", title: "",
param, param,
})), }))
});
if (query.visualizations.length) {
this.setState({ selectedVis: query.visualizations[0] });
}
}
});
}
}
selectVisualization(query, visualizationId) {
each(query.visualizations, visualization => {
if (visualization.id === visualizationId) {
this.setState({ selectedVis: visualization });
return false;
}
});
}
saveWidget() {
const { selectedVis, parameterMappings } = this.state;
this.setState({ saveInProgress: true });
this.props
.onConfirm(selectedVis, parameterMappings)
.then(() => {
this.props.dialog.close();
})
.catch(() => {
notification.error("Widget could not be added");
})
.finally(() => {
this.setState({ saveInProgress: false });
});
}
updateParamMappings(parameterMappings) {
this.setState({ parameterMappings });
}
renderVisualizationInput() {
let visualizationGroups = {};
if (this.state.selectedQuery) {
each(this.state.selectedQuery.visualizations, vis => {
visualizationGroups[vis.type] = visualizationGroups[vis.type] || [];
visualizationGroups[vis.type].push(vis);
});
}
visualizationGroups = values(visualizationGroups);
return (
<div>
<div className="form-group">
<label htmlFor="choose-visualization">Choose Visualization</label>
<Select
id="choose-visualization"
className="w-100"
defaultValue={first(this.state.selectedQuery.visualizations).id}
onChange={visualizationId => this.selectVisualization(this.state.selectedQuery, visualizationId)}>
{visualizationGroups.map(visualizations => (
<OptGroup label={visualizations[0].type} key={visualizations[0].type}>
{visualizations.map(visualization => (
<Option value={visualization.id} key={visualization.id}>
{visualization.name}
</Option>
))}
</OptGroup>
))}
</Select>
</div>
</div>
); );
if (query.visualizations.length > 0) {
setSelectedVisualization(first(query.visualizations));
} }
}
});
}
},
[dashboard]
);
render() { const saveWidget = useCallback(() => {
const existingParams = this.props.dashboard.getParametersDefs(); dialog.close({ visualization: selectedVisualization, parameterMappings }).catch(() => {
const { dialog } = this.props; notification.error("Widget could not be added");
});
}, [dialog, selectedVisualization, parameterMappings]);
const existingParams = dashboard.getParametersDefs();
return ( return (
<Modal <Modal
{...dialog.props} {...dialog.props}
title="Add Widget" title="Add Widget"
onOk={() => this.saveWidget()} onOk={saveWidget}
okButtonProps={{ okButtonProps={{
loading: this.state.saveInProgress, ...dialog.props.okButtonProps,
disabled: !this.state.selectedQuery, disabled: !selectedQuery || dialog.props.okButtonProps.disabled,
}} }}
okText="Add to Dashboard" okText="Add to Dashboard"
width={700}> width={700}>
<div data-test="AddWidgetDialog"> <div data-test="AddWidgetDialog">
<QuerySelector onChange={query => this.selectQuery(query)} /> <QuerySelector onChange={query => selectQuery(query ? query.id : null)} />
{this.state.selectedQuery && this.renderVisualizationInput()}
{this.state.parameterMappings.length > 0 && [ {selectedQuery && (
<VisualizationSelect
query={selectedQuery}
visualization={selectedVisualization}
onChange={setSelectedVisualization}
/>
)}
{parameterMappings.length > 0 && [
<label key="parameters-title" htmlFor="parameter-mappings"> <label key="parameters-title" htmlFor="parameter-mappings">
Parameters Parameters
</label>, </label>,
<ParameterMappingListInput <ParameterMappingListInput
key="parameters-list" key="parameters-list"
id="parameter-mappings" id="parameter-mappings"
mappings={this.state.parameterMappings} mappings={parameterMappings}
existingParams={existingParams} existingParams={existingParams}
onChange={mappings => this.updateParamMappings(mappings)} onChange={setParameterMappings}
/>, />,
]} ]}
</div> </div>
</Modal> </Modal>
); );
} }
}
AddWidgetDialog.propTypes = {
dialog: DialogPropType.isRequired,
dashboard: PropTypes.object.isRequired,
};
export default wrapDialog(AddWidgetDialog); export default wrapDialog(AddWidgetDialog);

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