Compare commits

..

90 Commits

Author SHA1 Message Date
Lingkai Kong
d965bc2653 change item element 2020-12-16 10:07:35 -08:00
Lingkai Kong
361308cb10 adjust for comment 2020-09-13 16:59:23 -07:00
Lingkai Kong
673c55609a fix CircleCI test 2020-09-10 13:54:32 -07:00
Lingkai Kong
97fc91f6e1 Fix query hash because of default limit 2020-09-10 13:30:25 -07:00
Lingkai Kong
311ec78090 Add frontend changes and connect to backend 2020-09-03 14:39:54 -07:00
Lingkai Kong
338c3b43e8 add default limit 1000 2020-09-03 13:16:33 -07: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
382 changed files with 19742 additions and 7844 deletions

View File

@@ -108,6 +108,11 @@ jobs:
- run:
name: Execute Cypress tests
command: npm run cypress run-ci
- run:
name: "Failure: output container logs to console"
command: |
docker-compose logs
when: on_fail
build-docker-image: *build-docker-image-job
build-preview-docker-image: *build-docker-image-job
workflows:

View File

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

View File

@@ -1,4 +1,4 @@
version: '3'
version: '2.2'
services:
server:
build: ../
@@ -14,6 +14,7 @@ services:
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
REDASH_RATELIMIT_ENABLED: "false"
REDASH_ENFORCE_CSRF: "true"
scheduler:
build: ../
command: scheduler

View File

@@ -6,8 +6,8 @@ from pathlib import Path
from shutil import copy
from collections import OrderedDict as odict
from importlib_metadata import entry_points
from importlib_resources import contents, is_resource, path
import importlib_metadata
import importlib_resources
# Name of the subdirectory
BUNDLE_DIRECTORY = "bundle"
@@ -25,18 +25,6 @@ if not extensions_directory.exists():
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):
"""Returns the dotted module path for the given entry point"""
return entry_point.pattern.match(entry_point.value).group("module")
@@ -77,18 +65,28 @@ def load_bundles():
"""
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)
module = entry_point_module(entry_point)
# 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(
'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
with path(module, BUNDLE_DIRECTORY) as bundle_dir:
bundles[entry_point.name] = list(bundle_dir.rglob("*"))
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
bundles[entry_point.name] = list(bundle_dir.rglob("*"))
return bundles

View File

@@ -126,4 +126,3 @@ case "$1" in
exec "$@"
;;
esac

View File

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

View File

@@ -1,17 +1,40 @@
module.exports = {
root: true,
extends: ["react-app", "plugin:compat/recommended", "prettier"],
plugins: ["jest", "compat", "no-only-tests"],
parser: "@typescript-eslint/parser",
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: {
"import/resolver": "webpack"
"import/resolver": "webpack",
},
env: {
browser: true,
node: true
node: true,
},
rules: {
// allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
"jsx-a11y/anchor-is-valid": "off",
}
},
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",
},
},
],
};

View File

@@ -16,7 +16,6 @@
@import "~antd/lib/pagination/style/index";
@import "~antd/lib/table/style/index";
@import "~antd/lib/popover/style/index";
@import "~antd/lib/icon/style/index";
@import "~antd/lib/tag/style/index";
@import "~antd/lib/grid/style/index";
@import "~antd/lib/switch/style/index";
@@ -31,6 +30,7 @@
@import "~antd/lib/badge/style/index";
@import "~antd/lib/card/style/index";
@import "~antd/lib/spin/style/index";
@import "~antd/lib/skeleton/style/index";
@import "~antd/lib/tabs/style/index";
@import "~antd/lib/notification/style/index";
@import "~antd/lib/collapse/style/index";
@@ -401,3 +401,14 @@
.@{checkbox-prefix-cls} + span {
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;
}
.ant-form-item-explain {
margin-top: 10px;
}
.alert-last-triggered {
color: @headings-color;
}

View File

@@ -32,17 +32,6 @@ body {
#application-root {
padding-bottom: 15px;
}
&.headless {
#application-root {
padding-top: 10px;
padding-bottom: 0;
}
.app-header-wrapper {
display: none;
}
}
}
#application-root {
@@ -89,46 +78,16 @@ strong {
}
}
// Fixed width layout for specific pages
@media (min-width: 768px) {
.settings-screen,
.home-page,
.page-dashboard-list,
.page-queries-list,
.page-alerts-list,
.alert-page,
.fixed-container {
.container {
width: 750px;
}
}
}
@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;
}
.settings-screen,
.home-page,
.page-dashboard-list,
.page-queries-list,
.page-alerts-list,
.alert-page,
.admin-page-layout {
.container {
width: 100%;
max-width: none;
}
}
@@ -255,7 +214,6 @@ text.slicetext {
}
}
.page-header-wrapper,
.page-header--new {
h3 {
margin: 0.2em 0;

View File

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

View File

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

View File

@@ -7,7 +7,6 @@
/** Load Vendors Dependencies **/
@import "~font-awesome/less/font-awesome";
@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/mixins";

View File

@@ -4,14 +4,13 @@ body.fixed-layout {
#application-root {
display: flex;
flex-direction: column;
flex-direction: row;
padding-bottom: 0;
width: 100vw;
height: 100vh;
> div {
flex-grow: 1;
.application-layout-content > div {
display: flex;
}
}
@@ -73,9 +72,6 @@ body.fixed-layout {
}
}
.embed__vis {
}
.query__vis {
table {
border: 1px solid #f0f0f0;
@@ -94,6 +90,7 @@ body.fixed-layout {
.embed__vis {
display: flex;
flex-flow: column;
width: 100%;
}
.embed-heading {
@@ -140,10 +137,6 @@ a.label-tag {
}
}
.schema-browser {
overflow-y: auto;
}
.query-page-wrapper {
display: flex;
flex-direction: column;
@@ -156,7 +149,6 @@ a.label-tag {
box-shadow: rgba(102, 136, 153, 0.15) 0 4px 9px -3px;
flex-grow: 1;
display: flex;
width: 100vw;
.resizable-component.react-resizable {
.react-resizable-handle-horizontal {
@@ -486,13 +478,6 @@ nav .rg-bottom {
}
}
.query-page-wrapper {
.container {
margin-left: 0;
margin-right: 0;
}
}
.datasource-small {
visibility: visible;
}

View File

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

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();
}, []);
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,39 @@
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>
<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>
</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

@@ -2,6 +2,8 @@ import { isObject, get } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import "./ErrorMessage.less";
function getErrorMessageByStatus(status, defaultMessage) {
switch (status) {
case 404:
@@ -37,17 +39,13 @@ export default function ErrorMessage({ error }) {
console.error(error);
return (
<div className="fixed-container" data-test="ErrorMessage">
<div className="container">
<div className="col-md-8 col-md-push-2">
<div className="error-state bg-white tiled">
<div className="error-state__icon">
<i className="zmdi zmdi-alert-circle-o" />
</div>
<div className="error-state__details">
<h4>{getErrorMessage(error)}</h4>
</div>
</div>
<div className="error-message-container" data-test="ErrorMessage">
<div className="error-state bg-white tiled">
<div className="error-state__icon">
<i className="zmdi zmdi-alert-circle-o" />
</div>
<div className="error-state__details">
<h4>{getErrorMessage(error)}</h4>
</div>
</div>
</div>

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

@@ -1,5 +1,5 @@
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 UniversalRouter from "universal-router";
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
@@ -14,6 +14,12 @@ function generateRouteKey() {
.substr(2);
}
export const CurrentRouteContext = React.createContext(null);
export function useCurrentRoute() {
return useContext(CurrentRouteContext);
}
export function stripBase(href) {
// Resolve provided link and '' (root) relative to document's base.
// If provided href is not related to current document (does not
@@ -53,7 +59,7 @@ export default function Router({ routes, onRouteChange }) {
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
// from this path - do nothing. It also prevents router from using outdated route in a case
@@ -95,6 +101,7 @@ export default function Router({ routes, onRouteChange }) {
return () => {
isAbandoned = true;
currentPathRef.current = null;
unlisten();
};
}, [routes]);
@@ -108,9 +115,11 @@ export default function Router({ routes, onRouteChange }) {
}
return (
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
{currentRoute.render(currentRoute)}
</ErrorBoundary>
<CurrentRouteContext.Provider value={currentRoute}>
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
{currentRoute.render(currentRoute)}
</ErrorBoundary>
</CurrentRouteContext.Provider>
);
}

View File

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

View File

@@ -2,8 +2,9 @@ import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth";
import { policy } from "@/services/policy";
import organizationStatus from "@/services/organizationStatus";
import ApplicationHeader from "./ApplicationHeader";
import ApplicationLayout from "./ApplicationLayout";
import ErrorMessage from "./ErrorMessage";
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
@@ -17,7 +18,7 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
useEffect(() => {
let isCancelled = false;
Promise.all([Auth.requireSession(), organizationStatus.refresh()])
Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()])
.then(() => {
if (!isCancelled) {
setIsAuthenticated(!!Auth.isAuthenticated());
@@ -47,8 +48,7 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
}
return (
<React.Fragment>
<ApplicationHeader />
<ApplicationLayout>
<React.Fragment key={currentRoute.key}>
<ErrorBoundary renderError={error => <ErrorMessage error={error} />}>
<ErrorBoundaryContext.Consumer>
@@ -58,7 +58,7 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
</ErrorBoundaryContext.Consumer>
</ErrorBoundary>
</React.Fragment>
</React.Fragment>
</ApplicationLayout>
);
}

View File

@@ -3,6 +3,7 @@ import Card from "antd/lib/card";
import Button from "antd/lib/button";
import Typography from "antd/lib/typography";
import { clientConfig } from "@/services/auth";
import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import OrgSettings from "@/services/organizationSettings";
@@ -65,8 +66,8 @@ function BeaconConsent() {
</div>
<div className="m-t-15">
<Text type="secondary">
You can change this setting anytime from the <a href="settings/organization">Organization Settings</a>{" "}
page.
You can change this setting anytime from the{" "}
<Link href="settings/organization">Organization Settings</Link> page.
</Text>
</div>
</Card>

View File

@@ -2,6 +2,7 @@ import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip";
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
import "./CodeBlock.less";
export default class CodeBlock extends React.Component {
@@ -59,7 +60,7 @@ export default class CodeBlock extends React.Component {
const copyButton = (
<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>
);

View File

@@ -7,6 +7,7 @@ import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import Steps from "antd/lib/steps";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import Link from "@/components/Link";
import { PreviewCard } from "@/components/PreviewCard";
import EmptyState from "@/components/items-list/components/EmptyState";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
@@ -118,9 +119,9 @@ class CreateSourceDialog extends React.Component {
{selectedType.type === "databricks" && (
<small>
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
<a href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
<Link href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
Driver Download Terms and Conditions
</a>
</Link>
.
</small>
)}
@@ -154,7 +155,7 @@ class CreateSourceDialog extends React.Component {
footer={
currentStep === StepEnum.SELECT_TYPE
? [
<Button key="cancel" onClick={() => dialog.dismiss()}>
<Button key="cancel" onClick={() => dialog.dismiss()} data-test="CreateSourceCancelButton">
Cancel
</Button>,
<Button key="submit" type="primary" disabled>
@@ -171,7 +172,7 @@ class CreateSourceDialog extends React.Component {
form="sourceForm"
type="primary"
loading={savingSource}
data-test="CreateSourceButton">
data-test="CreateSourceSaveButton">
Create
</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;
onDismiss: (handler: (result: RCancel) => Promise<void>) => void;
close: (result: ROk) => void;
dismiss: (result: RCancel) => void;
};
};

View File

@@ -100,7 +100,7 @@ function EditParameterSettingsDialog(props) {
return true;
}
function onConfirm(e) {
function onConfirm() {
// update title to default
if (!param.title) {
// forced to do this cause param won't update in time for save
@@ -109,8 +109,6 @@ function EditParameterSettingsDialog(props) {
}
props.dialog.close(param);
e.preventDefault(); // stops form redirect
}
return (
@@ -132,7 +130,7 @@ function EditParameterSettingsDialog(props) {
{isNew ? "Add Parameter" : "OK"}
</Button>,
]}>
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
<Form layout="horizontal" onFinish={onConfirm} id="paramForm">
{isNew && (
<NameInput
name={param.name}

View File

@@ -3,7 +3,12 @@ import PropTypes from "prop-types";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Button from "antd/lib/button";
import Icon from "antd/lib/icon";
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";
@@ -13,14 +18,14 @@ export default function QueryControlDropdown(props) {
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
<Menu.Item>
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
<Icon type="plus-circle" theme="filled" /> Add to Dashboard
<PlusCircleFilledIcon /> Add to Dashboard
</a>
</Menu.Item>
)}
{!props.query.isNew() && (
<Menu.Item>
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
<Icon type="share-alt" /> Embed Elsewhere
<ShareAltOutlinedIcon /> Embed Elsewhere
</a>
</Menu.Item>
)}
@@ -32,7 +37,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult}
embed={props.embed}
apiKey={props.apiKey}>
<Icon type="file" /> Download as CSV File
<FileOutlinedIcon /> Download as CSV File
</QueryResultsLink>
</Menu.Item>
<Menu.Item>
@@ -43,7 +48,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult}
embed={props.embed}
apiKey={props.apiKey}>
<Icon type="file" /> Download as TSV File
<FileOutlinedIcon /> Download as TSV File
</QueryResultsLink>
</Menu.Item>
<Menu.Item>
@@ -54,7 +59,7 @@ export default function QueryControlDropdown(props) {
queryResult={props.queryResult}
embed={props.embed}
apiKey={props.apiKey}>
<Icon type="file-excel" /> Download as Excel File
<FileExcelOutlinedIcon /> Download as Excel File
</QueryResultsLink>
</Menu.Item>
</Menu>
@@ -63,7 +68,7 @@ export default function QueryControlDropdown(props) {
return (
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
<Button data-test="QueryControlDropdownButton">
<Icon type="ellipsis" rotate={90} />
<EllipsisOutlinedIcon rotate={90} />
</Button>
</Dropdown>
);

View File

@@ -1,5 +1,6 @@
import React from "react";
import PropTypes from "prop-types";
import Link from "@/components/Link";
export default function QueryResultsLink(props) {
let href = "";
@@ -17,9 +18,9 @@ export default function QueryResultsLink(props) {
}
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}
</a>
</Link>
);
}

View File

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

View File

@@ -4,7 +4,8 @@ import PropTypes from "prop-types";
import cx from "classnames";
import Tooltip from "antd/lib/tooltip";
import Drawer from "antd/lib/drawer";
import Icon from "antd/lib/icon";
import Link from "@/components/Link";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import BigMessage from "@/components/BigMessage";
import DynamicComponent from "@/components/DynamicComponent";
@@ -149,9 +150,9 @@ export default class HelpTrigger extends React.Component {
{this.props.children}
</a>
) : (
<a href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
<Link href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
{this.props.children}
</a>
</Link>
)}
</Tooltip>
<Drawer
@@ -167,14 +168,14 @@ export default class HelpTrigger extends React.Component {
{url && (
<Tooltip title="Open page in a new window" placement="left">
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<a href={url} target="_blank">
<Link href={url} target="_blank">
<i className="fa fa-external-link" />
</a>
</Link>
</Tooltip>
)}
<Tooltip title="Close" placement="bottom">
<a onClick={this.closeDrawer}>
<Icon type="close" />
<CloseOutlinedIcon />
</a>
</Tooltip>
</div>
@@ -201,9 +202,9 @@ export default class HelpTrigger extends React.Component {
Something went wrong.
<br />
{/* 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
</a>{" "}
</Link>{" "}
to open the page in a new window.
</BigMessage>
)}

View File

@@ -1,6 +1,6 @@
import React from "react";
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";
export default class InputWithCopy extends React.Component {
@@ -42,7 +42,7 @@ export default class InputWithCopy extends React.Component {
render() {
const copyButton = (
<Tooltip title={this.state.copied || "Copy"}>
<Icon type="copy" style={{ cursor: "pointer" }} onClick={this.copy} />
<CopyOutlinedIcon style={{ cursor: "pointer" }} onClick={this.copy} />
</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 {...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 (
<BigMessage icon="fa-tags">
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>
);
}

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 Pagination from "antd/lib/pagination";
export default function Paginator({ page, itemsPerPage, totalCount, onChange }) {
if (totalCount <= itemsPerPage) {
const MIN_ITEMS_PER_PAGE = 5;
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) {
if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) {
return null;
}
return (
<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>
);
}
Paginator.propTypes = {
page: PropTypes.number.isRequired,
itemsPerPage: PropTypes.number.isRequired,
showPageSizeSelect: PropTypes.bool,
pageSize: PropTypes.number.isRequired,
totalCount: PropTypes.number.isRequired,
onPageSizeChange: PropTypes.func,
onChange: PropTypes.func,
};
Paginator.defaultProps = {
showPageSizeSelect: false,
onChange: () => {},
onPageSizeChange: () => {},
};

View File

@@ -8,7 +8,6 @@ import Select from "antd/lib/select";
import Table from "antd/lib/table";
import Popover from "antd/lib/popover";
import Button from "antd/lib/button";
import Icon from "antd/lib/icon";
import Tag from "antd/lib/tag";
import Input from "antd/lib/input";
import Radio from "antd/lib/radio";
@@ -19,6 +18,11 @@ import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger";
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";
const { Option } = Select;
@@ -181,7 +185,7 @@ export class ParameterMappingInput extends React.Component {
Existing dashboard parameter{" "}
{noExisting ? (
<Tooltip title="There are no dashboard parameters corresponding to this data type">
<Icon type="question-circle" theme="filled" />
<QuestionCircleFilledIcon />
</Tooltip>
) : null}
</Radio>
@@ -355,7 +359,7 @@ class MappingEditor extends React.Component {
visible={visible}
onVisibleChange={this.onVisibleChange}>
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
<Icon type="edit" />
<EditOutlinedIcon />
</Button>
</Popover>
);
@@ -434,10 +438,10 @@ class TitleEditor extends React.Component {
autoFocus
/>
<Button size="small" type="dashed" onClick={this.hide}>
<Icon type="close" />
<CloseOutlinedIcon />
</Button>
<Button size="small" type="dashed" onClick={this.save}>
<Icon type="check" />
<CheckOutlinedIcon />
</Button>
</div>
);
@@ -460,7 +464,7 @@ class TitleEditor extends React.Component {
visible={this.state.showPopup}
onVisibleChange={this.onPopupVisibleChange}>
<Button size="small" type="dashed">
<Icon type="edit" />
<EditOutlinedIcon />
</Button>
</Popover>
);

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;
@@ -17,9 +17,10 @@
}
&[data-dirty] {
.@{ant-prefix}-input, // covers also ant date component
.@{ant-prefix}-input,
.@{ant-prefix}-input-number,
.@{ant-prefix}-select-selection {
.@{ant-prefix}-select-selector,
.@{ant-prefix}-picker {
background-color: @input-dirty;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,11 +35,11 @@ CounterCard.defaultProps = {
const queryJobsColumns = [
{ title: "Queue", dataIndex: "origin" },
{ title: "Query ID", dataIndex: "meta.query_id" },
{ title: "Org ID", dataIndex: "meta.org_id" },
{ title: "Data Source ID", dataIndex: "meta.data_source_id" },
{ title: "User ID", dataIndex: "meta.user_id" },
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }),
{ title: "Query ID", dataIndex: ["meta", "query_id"] },
{ title: "Org ID", dataIndex: ["meta", "org_id"] },
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
{ title: "User ID", dataIndex: ["meta", "user_id"] },
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
];

View File

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

View File

@@ -2,6 +2,7 @@ import Input from "antd/lib/input";
import { includes, isEmpty } from "lodash";
import PropTypes from "prop-types";
import React from "react";
import Link from "@/components/Link";
import EmptyState from "@/components/items-list/components/EmptyState";
import "./CardsList.less";
@@ -44,10 +45,10 @@ export default class CardsList extends React.Component {
// 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}>
<Link 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>
</Link>
);
}

View File

@@ -1,6 +1,5 @@
import { trim } from "lodash";
import React, { useState } from "react";
import { axios } from "@/services/axios";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import DynamicComponent from "@/components/DynamicComponent";
@@ -8,6 +7,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import recordEvent from "@/services/recordEvent";
import { policy } from "@/services/policy";
import { Dashboard } from "@/services/dashboard";
function CreateDashboardDialog({ dialog }) {
const [name, setName] = useState("");
@@ -25,9 +25,9 @@ function CreateDashboardDialog({ dialog }) {
if (name !== "") {
setSaveInProgress(true);
axios.post("api/dashboards", { name }).then(data => {
Dashboard.save({ name }).then(data => {
dialog.close();
navigateTo(`dashboard/${data.slug}?edit`);
navigateTo(`${data.url}?edit`);
});
recordEvent("create", "dashboard");
}

View File

@@ -7,6 +7,7 @@ import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import Tooltip from "antd/lib/tooltip";
import Divider from "antd/lib/divider";
import Link from "@/components/Link";
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import notification from "@/services/notification";
@@ -40,11 +41,30 @@ function TextboxDialog({ dialog, isNew, ...props }) {
});
}, [dialog, isNew, text]);
const confirmDialogDismiss = useCallback(() => {
const originalText = props.text;
if (text !== originalText) {
Modal.confirm({
title: "Quit editing?",
content: "Changes you made so far will not be saved. Are you sure?",
okText: "Yes, quit",
okType: "danger",
onOk: () => dialog.dismiss(),
maskClosable: true,
autoFocusButton: null,
style: { top: 170 },
});
} else {
dialog.dismiss();
}
}, [dialog, text, props.text]);
return (
<Modal
{...dialog.props}
title={isNew ? "Add Textbox" : "Edit Textbox"}
onOk={saveWidget}
onCancel={confirmDialogDismiss}
okText={isNew ? "Add to Dashboard" : "Save"}
width={500}
wrapProps={{ "data-test": "TextboxDialog" }}>
@@ -59,9 +79,12 @@ function TextboxDialog({ dialog, isNew, ...props }) {
/>
<small>
Supports basic{" "}
<a target="_blank" rel="noopener noreferrer" href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
<Link
target="_blank"
rel="noopener noreferrer"
href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
<Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
</a>
</Link>
.
</small>
{text && (

View File

@@ -93,6 +93,7 @@
> .filters-wrapper {
flex-grow: 0;
flex-shrink: 0;
}
}
@@ -112,15 +113,36 @@
overflow: hidden;
}
.counter-visualization-content {
position: absolute;
left: 10px;
top: 15px;
right: 10px;
bottom: 15px;
height: auto;
overflow: hidden;
padding: 0;
.counter-visualization-container {
height: 100%;
.counter-visualization-content {
position: absolute;
left: 10px;
top: 15px;
right: 10px;
bottom: 15px;
height: auto;
overflow: hidden;
padding: 0;
}
}
}
.query-fixed-layout {
.visualization-renderer > .visualization-renderer-wrapper {
.counter-visualization-container {
// counter is too large on Query pages, so let's add some constraints
max-width: 600px;
max-height: 400px;
// center it
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
margin: auto;
}
}
}

View File

@@ -8,6 +8,7 @@ import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { currentUser } from "@/services/auth";
import recordEvent from "@/services/recordEvent";
import { formatDateTime } from "@/lib/utils";
import Link from "@/components/Link";
import Parameters from "@/components/Parameters";
import TimeAgo from "@/components/TimeAgo";
import Timer from "@/components/Timer";
@@ -30,27 +31,27 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
return compact([
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? (
<a href={downloadLink("csv")} download={downloadName("csv")} target="_self">
<Link href={downloadLink("csv")} download={downloadName("csv")} target="_self">
Download as CSV File
</a>
</Link>
) : (
"Download as CSV File"
)}
</Menu.Item>,
<Menu.Item key="download_tsv" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? (
<a href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
<Link href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
Download as TSV File
</a>
</Link>
) : (
"Download as TSV File"
)}
</Menu.Item>,
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? (
<a href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
<Link href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
Download as Excel File
</a>
</Link>
) : (
"Download as Excel File"
)}
@@ -58,7 +59,7 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
canViewQuery && (
<Menu.Item key="view_query">
<a href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</a>
<Link href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</Link>
</Menu.Item>
),
canEditParameters && (

View File

@@ -1,24 +1,29 @@
import React from "react";
import React, { useState, useReducer, useCallback } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Form from "antd/lib/form";
import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number";
import Checkbox from "antd/lib/checkbox";
import Button from "antd/lib/button";
import Upload from "antd/lib/upload";
import Icon from "antd/lib/icon";
import { includes, isFunction, filter, difference, isEmpty, some, isNumber, isBoolean } from "lodash";
import Select from "antd/lib/select";
import { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash";
import notification from "@/services/notification";
import Collapse from "@/components/Collapse";
import AceEditorInput from "@/components/AceEditorInput";
import { toHuman } from "@/lib/utils";
import { Field, Action, AntdForm } from "../proptypes";
import DynamicFormField, { FieldType } from "./DynamicFormField";
import getFieldLabel from "./getFieldLabel";
import helper from "./dynamicFormHelper";
import "./DynamicForm.less";
const ActionType = PropTypes.shape({
name: PropTypes.string.isRequired,
callback: PropTypes.func.isRequired,
type: PropTypes.string,
pullRight: PropTypes.bool,
disabledWhenDirty: PropTypes.bool,
});
const AntdFormType = PropTypes.shape({
validateFieldsAndScroll: PropTypes.func,
});
const fieldRules = ({ type, required, minLength }) => {
const requiredRule = required;
const minLengthRule = minLength && includes(["text", "email", "password"], type);
@@ -31,290 +36,206 @@ const fieldRules = ({ type, required, minLength }) => {
].filter(rule => rule);
};
class DynamicForm extends React.Component {
static propTypes = {
id: PropTypes.string,
fields: PropTypes.arrayOf(Field),
actions: PropTypes.arrayOf(Action),
feedbackIcons: PropTypes.bool,
hideSubmitButton: PropTypes.bool,
saveText: PropTypes.string,
onSubmit: PropTypes.func,
form: AntdForm.isRequired,
};
static defaultProps = {
id: null,
fields: [],
actions: [],
feedbackIcons: false,
hideSubmitButton: false,
saveText: "Save",
onSubmit: () => {},
};
constructor(props) {
super(props);
const hasFilledExtraField = some(props.fields, field => {
const { extra, initialValue, placeholder } = field;
return (
extra &&
(!isEmpty(initialValue) ||
isNumber(initialValue) ||
(isBoolean(initialValue) && initialValue.toString() !== placeholder))
);
});
const inProgressActions = {};
props.actions.forEach(action => (inProgressActions[action.name] = false));
this.state = {
isSubmitting: false,
showExtraFields: hasFilledExtraField,
inProgressActions,
};
this.actionCallbacks = this.props.actions.reduce(
(acc, cur) => ({
...acc,
[cur.name]: cur.callback,
}),
null
);
}
setActionInProgress = (actionName, inProgress) => {
this.setState(prevState => ({
inProgressActions: {
...prevState.inProgressActions,
[actionName]: inProgress,
},
}));
};
handleSubmit = e => {
this.setState({ isSubmitting: true });
e.preventDefault();
this.props.form.validateFieldsAndScroll((err, values) => {
Object.entries(values).forEach(([key, value]) => {
const initialValue = this.props.fields.find(f => f.name === key).initialValue;
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
values[key] = null;
}
});
if (!err) {
this.props.onSubmit(
values,
msg => {
const { setFieldsValue, getFieldsValue } = this.props.form;
this.setState({ isSubmitting: false });
setFieldsValue(getFieldsValue()); // reset form touched state
notification.success(msg);
},
msg => {
this.setState({ isSubmitting: false });
notification.error(msg);
}
);
} else this.setState({ isSubmitting: false });
});
};
handleAction = e => {
const actionName = e.target.dataset.action;
this.setActionInProgress(actionName, true);
this.actionCallbacks[actionName](() => {
this.setActionInProgress(actionName, false);
});
};
base64File = (fieldName, e) => {
if (e && e.fileList[0]) {
helper.getBase64(e.file).then(value => {
this.props.form.setFieldsValue({ [fieldName]: value });
});
function normalizeEmptyValuesToNull(fields, values) {
return mapValues(values, (value, key) => {
const { initialValue } = find(fields, { name: key }) || {};
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
return null;
}
};
return value;
});
}
renderUpload(field, props) {
const { getFieldDecorator, getFieldValue } = this.props.form;
const { name, initialValue } = field;
function DynamicFormFields({ fields, feedbackIcons, form }) {
return fields.map(field => {
const { name, type, initialValue, contentAfter } = field;
const fieldLabel = getFieldLabel(field);
const fileOptions = {
rules: fieldRules(field),
initialValue,
getValueFromEvent: this.base64File.bind(this, name),
};
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
const upload = (
<Upload {...props} beforeUpload={() => false}>
<Button disabled={disabled}>
<Icon type="upload" /> Click to upload
</Button>
</Upload>
);
return getFieldDecorator(name, fileOptions)(upload);
}
renderSelect(field, props) {
const { getFieldDecorator } = this.props.form;
const { name, options, mode, initialValue, readOnly, loading } = field;
const { Option } = Select;
const decoratorOptions = {
rules: fieldRules(field),
initialValue,
};
return getFieldDecorator(
const formItemProps = {
name,
decoratorOptions
)(
<Select
{...props}
optionFilterProp="children"
loading={loading || false}
mode={mode}
getPopupContainer={trigger => trigger.parentNode}>
{options &&
options.map(option => (
<Option key={`${option.value}`} value={option.value} disabled={readOnly}>
{option.name || option.value}
</Option>
))}
</Select>
);
}
renderField(field, props) {
const { getFieldDecorator } = this.props.form;
const { name, type, initialValue } = field;
const fieldLabel = field.title || toHuman(name);
const options = {
className: "m-b-10",
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
label: type === "checkbox" ? "" : fieldLabel,
rules: fieldRules(field),
valuePropName: type === "checkbox" ? "checked" : "value",
initialValue,
};
if (type === "checkbox") {
return getFieldDecorator(name, options)(<Checkbox {...props}>{fieldLabel}</Checkbox>);
} else if (type === "file") {
return this.renderUpload(field, props);
} else if (type === "select") {
return this.renderSelect(field, props);
} else if (type === "content") {
return field.content;
} else if (type === "number") {
return getFieldDecorator(name, options)(<InputNumber {...props} />);
} else if (type === "textarea") {
return getFieldDecorator(name, options)(<Input.TextArea {...props} />);
} else if (type === "ace") {
return getFieldDecorator(name, options)(<AceEditorInput {...props} />);
if (type === "file") {
formItemProps.valuePropName = "data-value";
formItemProps.getValueFromEvent = e => {
if (e && e.fileList[0]) {
helper.getBase64(e.file).then(value => {
form.setFieldsValue({ [name]: value });
});
}
return undefined;
};
}
return getFieldDecorator(name, options)(<Input {...props} />);
}
renderFields(fields) {
return fields.map(field => {
const FormItem = Form.Item;
const { name, title, type, readOnly, autoFocus, contentAfter } = field;
const fieldLabel = title || toHuman(name);
const { feedbackIcons, form } = this.props;
const formItemProps = {
className: "m-b-10",
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
label: type === "checkbox" ? "" : fieldLabel,
};
const fieldProps = {
...field.props,
className: "w-100",
name,
type,
readOnly,
autoFocus,
placeholder: field.placeholder,
"data-test": fieldLabel,
};
return (
<React.Fragment key={name}>
<FormItem {...formItemProps}>{this.renderField(field, fieldProps)}</FormItem>
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
</React.Fragment>
);
});
}
renderActions() {
return this.props.actions.map(action => {
const inProgress = this.state.inProgressActions[action.name];
const { isFieldsTouched } = this.props.form;
const actionProps = {
key: action.name,
htmlType: "button",
className: action.pullRight ? "pull-right m-t-10" : "m-t-10",
type: action.type,
disabled: isFieldsTouched() && action.disableWhenDirty,
loading: inProgress,
onClick: this.handleAction,
};
return (
<Button {...actionProps} data-action={action.name}>
{action.name}
</Button>
);
});
}
render() {
const submitProps = {
type: "primary",
htmlType: "submit",
className: "w-100 m-t-20",
disabled: this.state.isSubmitting,
loading: this.state.isSubmitting,
};
const { id, hideSubmitButton, saveText, fields } = this.props;
const { showExtraFields } = this.state;
const saveButton = !hideSubmitButton;
const extraFields = filter(fields, { extra: true });
const regularFields = difference(fields, extraFields);
return (
<Form id={id} className="dynamic-form" layout="vertical" onSubmit={this.handleSubmit}>
{this.renderFields(regularFields)}
{!isEmpty(extraFields) && (
<div className="extra-options">
<Button
type="dashed"
block
className="extra-options-button"
onClick={() => this.setState({ showExtraFields: !showExtraFields })}>
Additional Settings
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
</Button>
<Collapse collapsed={!showExtraFields} className="extra-options-content">
{this.renderFields(extraFields)}
</Collapse>
</div>
)}
{saveButton && <Button {...submitProps}>{saveText}</Button>}
{this.renderActions()}
</Form>
<React.Fragment key={name}>
<Form.Item {...formItemProps}>
<DynamicFormField field={field} form={form} />
</Form.Item>
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
</React.Fragment>
);
}
});
}
export default Form.create()(DynamicForm);
DynamicFormFields.propTypes = {
fields: PropTypes.arrayOf(FieldType),
feedbackIcons: PropTypes.bool,
form: AntdFormType.isRequired,
};
DynamicFormFields.defaultProps = {
fields: [],
feedbackIcons: false,
};
const reducerForActionSet = (state, action) => {
if (action.inProgress) {
state.add(action.actionName);
} else {
state.delete(action.actionName);
}
return new Set(state);
};
function DynamicFormActions({ actions, isFormDirty }) {
const [inProgressActions, setActionInProgress] = useReducer(reducerForActionSet, new Set());
const handleAction = useCallback(action => {
const actionName = action.name;
if (isFunction(action.callback)) {
setActionInProgress({ actionName, inProgress: true });
action.callback(() => {
setActionInProgress({ actionName, inProgress: false });
});
}
}, []);
return actions.map(action => (
<Button
key={action.name}
htmlType="button"
className={cx("m-t-10", { "pull-right": action.pullRight })}
type={action.type}
disabled={isFormDirty && action.disableWhenDirty}
loading={inProgressActions.has(action.name)}
onClick={() => handleAction(action)}>
{action.name}
</Button>
));
}
DynamicFormActions.propTypes = {
actions: PropTypes.arrayOf(ActionType),
isFormDirty: PropTypes.bool,
};
DynamicFormActions.defaultProps = {
actions: [],
isFormDirty: false,
};
export default function DynamicForm({
id,
fields,
actions,
feedbackIcons,
hideSubmitButton,
defaultShowExtraFields,
saveText,
onSubmit,
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showExtraFields, setShowExtraFields] = useState(defaultShowExtraFields);
const [form] = Form.useForm();
const extraFields = filter(fields, { extra: true });
const regularFields = difference(fields, extraFields);
const handleFinish = useCallback(
values => {
setIsSubmitting(true);
values = normalizeEmptyValuesToNull(fields, values);
onSubmit(
values,
msg => {
const { setFieldsValue, getFieldsValue } = form;
setIsSubmitting(false);
setFieldsValue(getFieldsValue()); // reset form touched state
notification.success(msg);
},
msg => {
setIsSubmitting(false);
notification.error(msg);
}
);
},
[form, fields, onSubmit]
);
const handleFinishFailed = useCallback(
({ errorFields }) => {
form.scrollToField(errorFields[0].name);
},
[form]
);
return (
<Form
form={form}
id={id}
className="dynamic-form"
layout="vertical"
onFinish={handleFinish}
onFinishFailed={handleFinishFailed}>
<DynamicFormFields fields={regularFields} feedbackIcons={feedbackIcons} form={form} />
{!isEmpty(extraFields) && (
<div className="extra-options">
<Button
type="dashed"
block
className="extra-options-button"
onClick={() => setShowExtraFields(currentShowExtraFields => !currentShowExtraFields)}>
Additional Settings
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
</Button>
<Collapse collapsed={!showExtraFields} className="extra-options-content">
<DynamicFormFields fields={extraFields} feedbackIcons={feedbackIcons} form={form} />
</Collapse>
</div>
)}
{!hideSubmitButton && (
<Button className="w-100 m-t-20" type="primary" htmlType="submit" disabled={isSubmitting}>
{saveText}
</Button>
)}
<DynamicFormActions actions={actions} isFormDirty={form.isFieldsTouched()} />
</Form>
);
}
DynamicForm.propTypes = {
id: PropTypes.string,
fields: PropTypes.arrayOf(FieldType),
actions: PropTypes.arrayOf(ActionType),
feedbackIcons: PropTypes.bool,
hideSubmitButton: PropTypes.bool,
defaultShowExtraFields: PropTypes.bool,
saveText: PropTypes.string,
onSubmit: PropTypes.func,
};
DynamicForm.defaultProps = {
id: null,
fields: [],
actions: [],
feedbackIcons: false,
hideSubmitButton: false,
defaultShowExtraFields: false,
saveText: "Save",
onSubmit: () => {},
};

View File

@@ -0,0 +1,82 @@
import React from "react";
import { get } from "lodash";
import PropTypes from "prop-types";
import getFieldLabel from "./getFieldLabel";
import {
AceEditorField,
CheckboxField,
ContentField,
FileField,
InputField,
NumberField,
SelectField,
TextAreaField,
} from "./fields";
export const FieldType = PropTypes.shape({
name: PropTypes.string.isRequired,
title: PropTypes.string,
type: PropTypes.oneOf([
"ace",
"text",
"textarea",
"email",
"password",
"number",
"checkbox",
"file",
"select",
"content",
]).isRequired,
initialValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool,
PropTypes.arrayOf(PropTypes.string),
PropTypes.arrayOf(PropTypes.number),
]),
content: PropTypes.node,
mode: PropTypes.string,
required: PropTypes.bool,
extra: PropTypes.bool,
readOnly: PropTypes.bool,
autoFocus: PropTypes.bool,
minLength: PropTypes.number,
placeholder: PropTypes.string,
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
loading: PropTypes.bool,
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
});
const FieldTypeComponent = {
checkbox: CheckboxField,
file: FileField,
select: SelectField,
number: NumberField,
textarea: TextAreaField,
ace: AceEditorField,
content: ContentField,
};
export default function DynamicFormField({ form, field, ...otherProps }) {
const { name, type, readOnly, autoFocus } = field;
const fieldLabel = getFieldLabel(field);
const fieldProps = {
...field.props,
className: "w-100",
name,
type,
readOnly,
autoFocus,
placeholder: field.placeholder,
"data-test": fieldLabel,
...otherProps,
};
const FieldComponent = get(FieldTypeComponent, type, InputField);
return <FieldComponent {...fieldProps} form={form} field={field} />;
}
DynamicFormField.propTypes = { field: FieldType.isRequired };

View File

@@ -1,5 +1,5 @@
import React from "react";
import { each, includes, isUndefined, isEmpty, isNil, map } from "lodash";
import { each, includes, isUndefined, isEmpty, isNil, map, get, some } from "lodash";
function orderedInputs(properties, order, targetOptions) {
const inputs = new Array(order.length);
@@ -124,8 +124,18 @@ function getBase64(file) {
});
}
function hasFilledExtraField(type, target) {
const extraOptions = get(type, "configuration_schema.extra_options", []);
return some(extraOptions, optionName => {
const defaultOptionValue = get(type, ["configuration_schema", "properties", optionName, "default"]);
const targetOptionValue = get(target, ["options", optionName]);
return !isNil(targetOptionValue) && targetOptionValue !== defaultOptionValue;
});
}
export default {
getFields,
updateTargetWithValues,
getBase64,
hasFilledExtraField,
};

View File

@@ -0,0 +1,6 @@
import React from "react";
import AceEditorInput from "@/components/AceEditorInput";
export default function AceEditorField({ form, field, ...otherProps }) {
return <AceEditorInput {...otherProps} />;
}

View File

@@ -0,0 +1,8 @@
import React from "react";
import Checkbox from "antd/lib/checkbox";
import getFieldLabel from "../getFieldLabel";
export default function CheckboxField({ form, field, ...otherProps }) {
const fieldLabel = getFieldLabel(field);
return <Checkbox {...otherProps}>{fieldLabel}</Checkbox>;
}

View File

@@ -0,0 +1,3 @@
export default function ContentField({ field }) {
return field.content;
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import Button from "antd/lib/button";
import Upload from "antd/lib/upload";
import UploadOutlinedIcon from "@ant-design/icons/UploadOutlined";
export default function FileField({ form, field, ...otherProps }) {
const { name, initialValue } = field;
const { getFieldValue } = form;
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
return (
<Upload {...otherProps} beforeUpload={() => false}>
<Button disabled={disabled}>
<UploadOutlinedIcon /> Click to upload
</Button>
</Upload>
);
}

View File

@@ -0,0 +1,6 @@
import React from "react";
import Input from "antd/lib/input";
export default function InputField({ form, field, ...otherProps }) {
return <Input {...otherProps} />;
}

View File

@@ -0,0 +1,6 @@
import React from "react";
import InputNumber from "antd/lib/input-number";
export default function NumberField({ form, field, ...otherProps }) {
return <InputNumber {...otherProps} />;
}

View File

@@ -0,0 +1,21 @@
import React from "react";
import Select from "antd/lib/select";
export default function SelectField({ form, field, ...otherProps }) {
const { readOnly } = field;
return (
<Select
{...otherProps}
optionFilterProp="children"
loading={field.loading || false}
mode={field.mode}
getPopupContainer={trigger => trigger.parentNode}>
{field.options &&
field.options.map(option => (
<Select.Option key={`${option.value}`} value={option.value} disabled={readOnly}>
{option.name || option.value}
</Select.Option>
))}
</Select>
);
}

View File

@@ -0,0 +1,6 @@
import React from "react";
import Input from "antd/lib/input";
export default function TextAreaField({ form, field, ...otherProps }) {
return <Input.TextArea {...otherProps} />;
}

View File

@@ -0,0 +1,8 @@
export { default as AceEditorField } from "./AceEditorField";
export { default as CheckboxField } from "./CheckboxField";
export { default as ContentField } from "./ContentField";
export { default as FileField } from "./FileField";
export { default as InputField } from "./InputField";
export { default as NumberField } from "./NumberField";
export { default as SelectField } from "./SelectField";
export { default as TextAreaField } from "./TextAreaField";

View File

@@ -0,0 +1,6 @@
import { toHuman } from "@/lib/utils";
export default function getFieldLabel(field) {
const { title, name } = field;
return title || toHuman(name);
}

View File

@@ -93,20 +93,21 @@ class DateParameter extends React.Component {
}
return (
<DateComponent
ref={this.dateComponentRef}
className={classNames("redash-datepicker", { "dynamic-value": hasDynamicValue }, className)}
onSelect={onSelect}
suffixIcon={
<DynamicButton
options={DYNAMIC_DATE_OPTIONS}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
}
{...additionalAttributes}
/>
<div className="date-parameter">
<DateComponent
ref={this.dateComponentRef}
className={classNames("redash-datepicker", { "dynamic-value": hasDynamicValue }, className)}
onSelect={onSelect}
suffixIcon={null}
{...additionalAttributes}
/>
<DynamicButton
options={DYNAMIC_DATE_OPTIONS}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
</div>
);
}
}

View File

@@ -107,6 +107,11 @@ const DYNAMIC_DATE_OPTIONS = [
.value()[0]
.format("MMM D") + " - Today",
},
{
name: "Last 12 months",
value: getDynamicDateRangeFromString("d_last_12_months"),
label: null,
},
];
const DYNAMIC_DATETIME_OPTIONS = [
@@ -203,21 +208,22 @@ class DateRangeParameter extends React.Component {
}
return (
<DateRangeComponent
ref={this.dateRangeComponentRef}
className={classNames("redash-datepicker date-range-input", { "dynamic-value": hasDynamicValue }, className)}
onSelect={onSelect}
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
suffixIcon={
<DynamicButton
options={options}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
}
{...additionalAttributes}
/>
<div className="data-range-parameter">
<DateRangeComponent
ref={this.dateRangeComponentRef}
className={classNames("redash-datepicker date-range-input", { "dynamic-value": hasDynamicValue }, className)}
onSelect={onSelect}
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
suffixIcon={null}
{...additionalAttributes}
/>
<DynamicButton
options={options}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
</div>
);
}
}

View File

@@ -2,12 +2,15 @@ import React, { useRef } from "react";
import PropTypes from "prop-types";
import { isFunction, get, findIndex } from "lodash";
import Dropdown from "antd/lib/dropdown";
import Icon from "antd/lib/icon";
import Menu from "antd/lib/menu";
import Typography from "antd/lib/typography";
import { DynamicDateType } from "@/services/parameters/DateParameter";
import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter";
import ArrowLeftOutlinedIcon from "@ant-design/icons/ArrowLeftOutlined";
import ThunderboltTwoToneIcon from "@ant-design/icons/ThunderboltTwoTone";
import ThunderboltOutlinedIcon from "@ant-design/icons/ThunderboltOutlined";
import "./DynamicButton.less";
const { Text } = Typography;
@@ -28,7 +31,7 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
{enabled && <Menu.Divider />}
{enabled && (
<Menu.Item>
<Icon type="arrow-left" />
<ArrowLeftOutlinedIcon />
<Text type="secondary">Back to Static Value</Text>
</Menu.Item>
)}
@@ -45,7 +48,13 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
className="dynamic-button"
placement="bottomRight"
trigger={["click"]}
icon={<Icon type="thunderbolt" theme={enabled ? "twoTone" : "outlined"} className="dynamic-icon" />}
icon={
enabled ? (
<ThunderboltTwoToneIcon className="dynamic-icon" />
) : (
<ThunderboltOutlinedIcon className="dynamic-icon" />
)
}
getPopupContainer={() => containerRef.current}
data-test="DynamicButton"
/>

View File

@@ -1,8 +1,10 @@
@import '../../assets/less/inc/variables';
@import "../../assets/less/inc/variables";
.redash-datepicker {
.ant-calendar-picker-clear {
right: 35px;
padding-right: 35px !important;
&.ant-picker-range .ant-picker-clear {
right: 35px !important;
background: transparent;
}
@@ -14,17 +16,19 @@
& ::placeholder {
color: @text-color !important;
}
&.date-range-input {
.ant-calendar-range-picker-input {
width: 100%;
text-align: left;
.ant-picker-active-bar {
opacity: 0;
}
.ant-calendar-range-picker-separator,
.ant-calendar-range-picker-input:not(:first-child) {
.ant-picker-separator {
display: none;
}
.ant-picker-input:not(:first-child) {
width: 0;
}
}
}
}

View File

@@ -0,0 +1,41 @@
import React from "react";
type DefaultStepKey = "dataSources" | "queries" | "alerts" | "dashboards" | "users";
export type StepKey<K> = DefaultStepKey | K;
export interface StepItem<K> {
key: StepKey<K>;
node: React.ReactNode;
}
export interface EmptyStateProps<K = unknown> {
header?: string;
icon?: string;
description: string;
illustration: string;
illustrationPath?: string;
helpLink: string;
onboardingMode?: boolean;
showAlertStep?: boolean;
showDashboardStep?: boolean;
showDataSourceStep?: boolean;
showInviteStep?: boolean;
getStepsItems?: (items: Array<StepItem<K>>) => Array<StepItem<K>>;
}
declare class EmptyState<R> extends React.Component<EmptyStateProps<R>> {}
export default EmptyState;
export interface StepProps {
show: boolean;
completed: boolean;
url?: string;
urlText?: string;
text: string;
onClick?: () => void;
}
export declare const Step: React.FunctionComponent<StepProps>;

View File

@@ -2,21 +2,22 @@ import { keys, some } from "lodash";
import React, { useCallback } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Link from "@/components/Link";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { currentUser } from "@/services/auth";
import organizationStatus from "@/services/organizationStatus";
import "./empty-state.less";
function Step({ show, completed, text, url, urlText, onClick }) {
export function Step({ show, completed, text, url, urlText, onClick }) {
if (!show) {
return null;
}
return (
<li className={classNames({ done: completed })}>
<a href={url} onClick={onClick}>
<Link href={url} onClick={onClick}>
{urlText}
</a>{" "}
</Link>{" "}
{text}
</li>
);
@@ -46,10 +47,13 @@ function EmptyState({
onboardingMode,
showAlertStep,
showDashboardStep,
showDataSourceStep,
showInviteStep,
getStepsItems,
illustrationPath,
}) {
const isAvailable = {
dataSource: true,
dataSource: showDataSourceStep,
query: true,
alert: showAlertStep,
dashboard: showDashboardStep,
@@ -75,6 +79,92 @@ function EmptyState({
return null;
}
const renderDataSourcesStep = () => {
if (currentUser.isAdmin) {
return (
<Step
key="dataSources"
show={isAvailable.dataSource}
completed={isCompleted.dataSource}
url="data_sources/new"
urlText="Connect"
text="a Data Source"
/>
);
}
return (
<Step
key="dataSources"
show={isAvailable.dataSource}
completed={isCompleted.dataSource}
text="Ask an account admin to connect a data source"
/>
);
};
const defaultStepsItems = [
{
key: "dataSources",
node: renderDataSourcesStep(),
},
{
key: "queries",
node: (
<Step
key="queries"
show={isAvailable.query}
completed={isCompleted.query}
url="queries/new"
urlText="Create"
text="your first Query"
/>
),
},
{
key: "alerts",
node: (
<Step
key="alerts"
show={isAvailable.alert}
completed={isCompleted.alert}
url="alerts/new"
urlText="Create"
text="your first Alert"
/>
),
},
{
key: "dashboards",
node: (
<Step
key="dashboards"
show={isAvailable.dashboard}
completed={isCompleted.dashboard}
onClick={showCreateDashboardDialog}
urlText="Create"
text="your first Dashboard"
/>
),
},
{
key: "users",
node: (
<Step
key="users"
show={isAvailable.inviteUsers}
completed={isCompleted.inviteUsers}
url="users/new"
urlText="Invite"
text="your team members"
/>
),
},
];
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
return (
<div className="empty-state bg-white tiled">
<div className="empty-state__summary">
@@ -83,66 +173,17 @@ function EmptyState({
<i className={icon} />
</h2>
<p>{description}</p>
<img
src={"/static/images/illustrations/" + illustration + ".svg"}
alt={illustration + " Illustration"}
width="75%"
/>
<img src={imageSource} alt={illustration + " Illustration"} width="75%" />
</div>
<div className="empty-state__steps">
<h4>Let&apos;s get started</h4>
<ol>
{currentUser.isAdmin && (
<Step
show={isAvailable.dataSource}
completed={isCompleted.dataSource}
url="data_sources/new"
urlText="Connect"
text="a Data Source"
/>
)}
{!currentUser.isAdmin && (
<Step
show={isAvailable.dataSource}
completed={isCompleted.dataSource}
text="Ask an account admin to connect a data source"
/>
)}
<Step
show={isAvailable.query}
completed={isCompleted.query}
url="queries/new"
urlText="Create"
text="your first Query"
/>
<Step
show={isAvailable.alert}
completed={isCompleted.alert}
url="alerts/new"
urlText="Create"
text="your first Alert"
/>
<Step
show={isAvailable.dashboard}
completed={isCompleted.dashboard}
onClick={showCreateDashboardDialog}
urlText="Create"
text="your first Dashboard"
/>
<Step
show={isAvailable.inviteUsers}
completed={isCompleted.inviteUsers}
url="users/new"
urlText="Invite"
text="your team members"
/>
</ol>
<ol>{stepsItems.map(item => item.node)}</ol>
<p>
Need more support?{" "}
<a href={helpLink} target="_blank" rel="noopener noreferrer">
<Link href={helpLink} target="_blank" rel="noopener noreferrer">
See our Help
<i className="fa fa-external-link m-l-5" aria-hidden="true" />
</a>
</Link>
</p>
</div>
</div>
@@ -154,12 +195,16 @@ EmptyState.propTypes = {
header: PropTypes.string,
description: PropTypes.string.isRequired,
illustration: PropTypes.string.isRequired,
illustrationPath: PropTypes.string,
helpLink: PropTypes.string.isRequired,
onboardingMode: PropTypes.bool,
showAlertStep: PropTypes.bool,
showDashboardStep: PropTypes.bool,
showDataSourceStep: PropTypes.bool,
showInviteStep: PropTypes.bool,
getStepItems: PropTypes.func,
};
EmptyState.defaultProps = {
@@ -169,6 +214,7 @@ EmptyState.defaultProps = {
onboardingMode: false,
showAlertStep: false,
showDashboardStep: false,
showDataSourceStep: true,
showInviteStep: false,
};

View File

@@ -24,12 +24,6 @@ export default function DetailsPageSidebar({
return (
<React.Fragment>
<Sidebar.Menu items={items} selected={controller.params.currentPage} />
<Sidebar.PageSizeSelect
className="m-b-10"
options={controller.pageSizeOptions}
value={controller.itemsPerPage}
onChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
/>
{canAddMembers && (
<Button className="w-100 m-t-5" type="primary" onClick={onAddMembersClick}>
<i className="fa fa-plus m-r-5" />

View File

@@ -3,6 +3,42 @@ import React from "react";
import PropTypes from "prop-types";
import hoistNonReactStatics from "hoist-non-react-statics";
import { clientConfig } from "@/services/auth";
import { AxiosError } from "axios";
export interface PaginationOptions {
page?: number;
itemsPerPage?: number;
}
export interface Controller<I, P = any> {
params: P; // TODO: Find out what params is (except merging with props)
isLoaded: boolean;
isEmpty: boolean;
// search
searchTerm?: string;
updateSearch: (searchTerm: string) => void;
// tags
selectedTags: string[];
updateSelectedTags: (selectedTags: string[]) => void;
// sorting
orderByField?: string;
orderByReverse: boolean;
toggleSorting: (orderByField: string) => void;
// pagination
page: number;
itemsPerPage: number;
totalItemsCount: number;
pageSizeOptions: number[];
pageItems: I[];
updatePagination: (options: PaginationOptions) => void; // ({ page: number, itemsPerPage: number }) => void
handleError: (error: any) => void; // TODO: Find out if error is string or object or Exception.
}
export const ControllerType = PropTypes.shape({
// values of props declared by wrapped component and some additional props from items list
@@ -35,15 +71,40 @@ export const ControllerType = PropTypes.shape({
handleError: PropTypes.func.isRequired, // (error) => void
});
export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
class ItemsListWrapper extends React.Component {
export type GenericItemSourceError = AxiosError | Error;
export interface ItemsListWrapperProps {
onError?: (error: AxiosError | Error) => void;
children: React.ReactNode;
}
interface ItemsListWrapperState<I, P = any> extends Controller<I, P> {
totalCount?: number;
update: () => void;
}
type ItemsSource = any; // TODO: Type ItemsSource
type StateStorage = any; // TODO: Type StateStore
export interface ItemsListWrappedComponentProps<I, P = any> {
controller: Controller<I, P>;
}
export function wrap<I, P = any>(
WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>,
createItemsSource: () => ItemsSource,
createStateStorage: () => StateStorage
) {
class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> {
private _itemsSource: ItemsSource;
static propTypes = {
onError: PropTypes.func,
children: PropTypes.node,
};
static defaultProps = {
onError: error => {
onError: (error: GenericItemSourceError) => {
// Allow calling chain to roll up, and then throw the error in global context
setTimeout(() => {
throw error;
@@ -52,7 +113,7 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
children: null,
};
constructor(props) {
constructor(props: ItemsListWrapperProps) {
super(props);
const stateStorage = createStateStorage();
@@ -73,7 +134,9 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
this.setState(this.getState({ ...state, isLoaded: true }));
};
itemsSource.onError = error => this.props.onError(error);
itemsSource.onError = (error: GenericItemSourceError) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.props.onError!(error);
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
@@ -93,13 +156,22 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
}
componentWillUnmount() {
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onBeforeUpdate = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onAfterUpdate = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onError = () => {};
}
// eslint-disable-next-line class-methods-use-this
getState({ isLoaded, totalCount, pageItems, params, ...rest }) {
getState({
isLoaded,
totalCount,
pageItems,
params,
...rest
}: ItemsListWrapperState<I, P>): ItemsListWrapperState<I, P> {
return {
...rest,
@@ -110,9 +182,9 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
isLoaded,
isEmpty: !isLoaded || totalCount === 0,
totalItemsCount: isLoaded ? totalCount : 0,
pageSizeOptions: clientConfig.pageSizeOptions,
pageItems: isLoaded ? pageItems : [],
totalItemsCount: totalCount || 0,
pageSizeOptions: (clientConfig as any).pageSizeOptions, // TODO: Type auth.js
pageItems: pageItems || [],
};
}

View File

@@ -0,0 +1,51 @@
export interface ItemsSourceOptions<I = any> extends Partial<ItemsSourceState> {
getRequest?: (params: any, context: any) => any; // TODO: Add stricter types
doRequest?: () => any; // TODO: Add stricter type
processResults?: () => any; // TODO: Add stricter type
isPlainList?: boolean;
sortByIteratees?: { [fieldName: string]: (a: I) => number };
}
export interface GetResourceContext extends ItemsSourceState {
params: {
currentPage: number;
// TODO: Add more context parameters
};
}
export type GetResourceRequest = any; // TODO: Add stricter type
export interface ItemsPage<INPUT = any> {
count: number;
page: number;
page_size: number;
results: INPUT[];
}
export interface ResourceItemsSourceOptions<INPUT = any, ITEM = any> extends ItemsSourceOptions {
getResource: (context: GetResourceContext) => (request: GetResourceRequest) => Promise<INPUT[]>;
getItemProcessor?: () => (input: INPUT) => ITEM;
}
export type ItemsSourceState<ITEM = any> = {
page: number;
itemsPerPage: number;
orderByField: string;
orderByReverse: boolean;
searchTerm: string;
selectedTags: string[];
totalCount: number;
pageItems: ITEM[];
allItems: ITEM[] | undefined;
params: {
pageTitle?: string;
} & { [key: string]: string | number };
};
declare class ItemsSource {
constructor(options: ItemsSourceOptions);
}
declare class ResourceItemsSource<I> {
constructor(options: ResourceItemsSourceOptions<I>);
}

View File

@@ -10,6 +10,8 @@ export class ItemsSource {
onError = null;
sortByIteratees = undefined;
getCallbackContext = () => null;
_beforeUpdate() {
@@ -41,21 +43,34 @@ export class ItemsSource {
extend(customParams, params);
},
};
return this._beforeUpdate().then(() =>
this._fetcher
return this._beforeUpdate().then(() => {
const fetchToken = Math.random()
.toString(36)
.substr(2);
this._currentFetchToken = fetchToken;
return this._fetcher
.fetch(changes, state, context)
.then(({ results, count, allResults }) => {
this._pageItems = results;
this._allItems = allResults || null;
this._paginator.setTotalCount(count);
this._params = { ...this._params, ...customParams };
return this._afterUpdate();
if (this._currentFetchToken === fetchToken) {
this._pageItems = results;
this._allItems = allResults || null;
this._paginator.setTotalCount(count);
this._params = { ...this._params, ...customParams };
return this._afterUpdate();
}
})
.catch(error => this.handleError(error))
);
.catch(error => this.handleError(error));
});
}
constructor({ getRequest, doRequest, processResults, isPlainList = false, ...defaultState }) {
constructor({
getRequest,
doRequest,
processResults,
isPlainList = false,
sortByIteratees = undefined,
...defaultState
}) {
if (!isFunction(getRequest)) {
getRequest = identity;
}
@@ -64,6 +79,8 @@ export class ItemsSource {
? new PlainListFetcher({ getRequest, doRequest, processResults })
: new PaginatedListFetcher({ getRequest, doRequest, processResults });
this.sortByIteratees = sortByIteratees;
this.setState(defaultState);
this._pageItems = [];
@@ -87,7 +104,7 @@ export class ItemsSource {
setState(state) {
this._paginator = new Paginator(state);
this._sorter = new Sorter(state);
this._sorter = new Sorter(state, this.sortByIteratees);
this._searchTerm = state.searchTerm || "";
this._selectedTags = state.selectedTags || [];

View File

@@ -24,6 +24,8 @@ export default class Sorter {
reverse = false;
sortByIteratees = null;
get compiled() {
return compile(this.field, this.reverse);
}
@@ -42,9 +44,10 @@ export default class Sorter {
this.reverse = !!value; // cast to boolean
}
constructor({ orderByField, orderByReverse } = {}) {
constructor({ orderByField, orderByReverse } = {}, sortByIteratees = undefined) {
this.setField(orderByField);
this.setReverse(orderByReverse);
this.sortByIteratees = sortByIteratees;
}
toggleField(field) {
@@ -61,7 +64,8 @@ export default class Sorter {
sort(items) {
if (this.field) {
items = sortBy(items, this.field);
const customIteratee = this.sortByIteratees && this.sortByIteratees[this.field];
items = sortBy(items, customIteratee ? [customIteratee] : this.field);
if (this.reverse) {
items.reverse();
}

View File

@@ -1,8 +1,9 @@
import { isFunction, map, filter, extend, omit, identity } from "lodash";
import { isFunction, map, filter, extend, omit, identity, range, isEmpty } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Table from "antd/lib/table";
import Skeleton from "antd/lib/skeleton";
import FavoritesControl from "@/components/FavoritesControl";
import TimeAgo from "@/components/TimeAgo";
import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils";
@@ -141,7 +142,7 @@ export default class ItemsTable extends React.Component {
return extend(omit(column, ["field", "orderByField", "render"]), {
key: "column" + index,
dataIndex: "item[" + JSON.stringify(column.field) + "]",
dataIndex: ["item", column.field],
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
onHeaderCell,
render,
@@ -151,8 +152,10 @@ export default class ItemsTable extends React.Component {
}
render() {
const columns = this.prepareColumns();
const rows = map(this.props.items, (item, index) => ({ key: "row" + index, item }));
const tableDataProps = {
columns: this.prepareColumns(),
dataSource: map(this.props.items, (item, index) => ({ key: "row" + index, item })),
};
// Bind events only if `onRowClick` specified
const onTableRow = isFunction(this.props.onRowClick)
@@ -164,17 +167,27 @@ export default class ItemsTable extends React.Component {
: null;
const { showHeader } = this.props;
if (this.props.loading) {
if (isEmpty(tableDataProps.dataSource)) {
tableDataProps.columns = tableDataProps.columns.map(column => ({
...column,
sorter: false,
render: () => <Skeleton active paragraph={false} />,
}));
tableDataProps.dataSource = range(10).map(key => ({ key: `${key}` }));
} else {
tableDataProps.loading = { indicator: null };
}
}
return (
<Table
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
loading={this.props.loading}
columns={columns}
showHeader={showHeader}
dataSource={rows}
rowKey={row => row.key}
pagination={false}
onRow={onTableRow}
{...tableDataProps}
/>
);
}

View File

@@ -3,7 +3,7 @@ import React, { useState, useCallback, useEffect } from "react";
import PropTypes from "prop-types";
import Input from "antd/lib/input";
import AntdMenu from "antd/lib/menu";
import Select from "antd/lib/select";
import Link from "@/components/Link";
import TagsList from "@/components/TagsList";
/*
@@ -60,7 +60,7 @@ export function Menu({ items, selected }) {
<AntdMenu className="invert-stripe-position" mode="inline" selectable={false} selectedKeys={[selected]}>
{map(items, item => (
<AntdMenu.Item key={item.key} className="m-0">
<a href={item.href}>
<Link href={item.href}>
{isString(item.icon) && item.icon !== "" && (
<span className="btn-favourite m-r-5">
<i className={item.icon} aria-hidden="true" />
@@ -68,7 +68,7 @@ export function Menu({ items, selected }) {
)}
{isFunction(item.icon) && (item.icon(item) || null)}
{item.title}
</a>
</Link>
</AntdMenu.Item>
))}
</AntdMenu>
@@ -147,27 +147,3 @@ Tags.propTypes = {
url: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
/*
PageSizeSelect
*/
export function PageSizeSelect({ options, value, onChange, ...props }) {
return (
<div {...props}>
<Select className="w-100" defaultValue={value} onChange={onChange}>
{map(options, option => (
<Select.Option key={option} value={option}>
{option} results
</Select.Option>
))}
</Select>
</div>
);
}
PageSizeSelect.propTypes = {
options: PropTypes.arrayOf(PropTypes.number).isRequired,
value: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
};

View File

@@ -42,7 +42,7 @@ Content.defaultProps = defaultProps;
// Layout
export default function Layout({ className, children, ...props }) {
export default function Layout({ children, className = undefined, ...props }) {
return (
<div className={classNames("layout-with-sidebar", className)} {...props}>
{children}

View File

@@ -9,7 +9,7 @@
margin: 0;
> .layout-content {
flex: 0 0 auto;
flex: 1 0 auto;
width: 75%;
order: 0;
margin: 0;
@@ -18,6 +18,7 @@
> .layout-sidebar {
flex: 0 0 auto;
width: 25%;
max-width: 350px;
order: 1;
margin: 0;
padding: 0 0 0 @spacing;
@@ -34,6 +35,7 @@
> .layout-sidebar {
width: 100%;
max-width: none;
order: 0;
margin: 0 0 @spacing 0;
padding: 0;

View File

@@ -31,53 +31,6 @@ export const RefreshScheduleDefault = {
until: null,
};
export const Field = PropTypes.shape({
name: PropTypes.string.isRequired,
title: PropTypes.string,
type: PropTypes.oneOf([
"ace",
"text",
"textarea",
"email",
"password",
"number",
"checkbox",
"file",
"select",
"content",
]).isRequired,
initialValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool,
PropTypes.arrayOf(PropTypes.string),
PropTypes.arrayOf(PropTypes.number),
]),
content: PropTypes.node,
mode: PropTypes.string,
required: PropTypes.bool,
extra: PropTypes.bool,
readOnly: PropTypes.bool,
autoFocus: PropTypes.bool,
minLength: PropTypes.number,
placeholder: PropTypes.string,
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
loading: PropTypes.bool,
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
});
export const Action = PropTypes.shape({
name: PropTypes.string.isRequired,
callback: PropTypes.func.isRequired,
type: PropTypes.string,
pullRight: PropTypes.bool,
disabledWhenDirty: PropTypes.bool,
});
export const AntdForm = PropTypes.shape({
validateFieldsAndScroll: PropTypes.func,
});
export const UserProfile = PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
@@ -95,10 +48,10 @@ export const Destination = PropTypes.shape({
});
export const Query = PropTypes.shape({
id: PropTypes.number.isRequired,
id: PropTypes.any.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
data_source_id: PropTypes.number.isRequired,
data_source_id: PropTypes.any.isRequired,
created_at: PropTypes.string.isRequired,
updated_at: PropTypes.string,
user: UserProfile,
@@ -119,7 +72,7 @@ export const AlertOptions = PropTypes.shape({
});
export const Alert = PropTypes.shape({
id: PropTypes.number,
id: PropTypes.any,
name: PropTypes.string,
created_at: PropTypes.string,
last_triggered_at: PropTypes.string,

View File

@@ -4,7 +4,8 @@ import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import List from "antd/lib/list";
import Icon from "antd/lib/icon";
import Link from "@/components/Link";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import { Dashboard } from "@/services/dashboard";
@@ -38,7 +39,7 @@ function AddToDashboardDialog({ dialog, visualization }) {
function addWidgetToDashboard() {
// Load dashboard with all widgets
Dashboard.get({ slug: selectedDashboard.slug })
Dashboard.get(selectedDashboard)
.then(dashboard => {
dashboard.addWidget(visualization);
return dashboard;
@@ -51,9 +52,9 @@ function AddToDashboardDialog({ dialog, visualization }) {
notification.success(
"Widget added to dashboard",
<React.Fragment>
<a href={`dashboard/${dashboard.slug}`} onClick={() => notification.close(key)}>
<Link href={`${dashboard.url}`} onClick={() => notification.close(key)}>
{dashboard.name}
</a>
</Link>
<QueryTagsControl isDraft={dashboard.is_draft} tags={dashboard.tags} />
</React.Fragment>,
{ key }
@@ -88,7 +89,7 @@ function AddToDashboardDialog({ dialog, visualization }) {
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
suffix={
<Icon type="close" className={searchTerm === "" ? "hidden" : null} onClick={() => setSearchTerm("")} />
<CloseOutlinedIcon className={searchTerm === "" ? "hidden" : null} onClick={() => setSearchTerm("")} />
}
/>
)}
@@ -103,7 +104,7 @@ function AddToDashboardDialog({ dialog, visualization }) {
renderItem={d => (
<List.Item
key={`dashboard-${d.id}`}
actions={selectedDashboard ? [<Icon type="close" onClick={() => setSelectedDashboard(null)} />] : []}
actions={selectedDashboard ? [<CloseOutlinedIcon onClick={() => setSelectedDashboard(null)} />] : []}
onClick={selectedDashboard ? null : () => setSelectedDashboard(d)}>
<div className="add-to-dashboard-dialog-item-content">
{d.name}

View File

@@ -0,0 +1,37 @@
import React, { useCallback } from "react";
import PropTypes from "prop-types";
import recordEvent from "@/services/recordEvent";
import Checkbox from "antd/lib/checkbox";
import Tooltip from "antd/lib/tooltip";
export default function AutoLimitCheckbox({ available, checked, onChange }) {
const handleClick = useCallback(() => {
recordEvent("checkbox_auto_limit", "screen", "query_editor", { state: !checked });
onChange(!checked);
}, [checked, onChange]);
let tooltipMessage = null;
if (!available) {
tooltipMessage = "Auto limiting is not available for this Data Source type.";
} else {
tooltipMessage = "Auto limit results to first 1000 rows.";
}
return (
<Tooltip placement="top" title={tooltipMessage}>
<Checkbox
className="query-editor-controls-checkbox"
disabled={!available}
onClick={handleClick}
checked={available && checked}>
LIMIT 1000
</Checkbox>
</Tooltip>
);
}
AutoLimitCheckbox.propTypes = {
available: PropTypes.bool,
checked: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};

View File

@@ -1,4 +1,4 @@
import { isFunction, map, filter, fromPairs } from "lodash";
import { isFunction, map, filter, fromPairs, noop } from "lodash";
import React, { useEffect } from "react";
import PropTypes from "prop-types";
import Tooltip from "antd/lib/tooltip";
@@ -8,6 +8,7 @@ import KeyboardShortcuts, { humanReadableShortcut } from "@/services/KeyboardSho
import AutocompleteToggle from "./AutocompleteToggle";
import "./QueryEditorControls.less";
import AutoLimitCheckbox from "@/components/queries/QueryEditor/AutoLimitCheckbox";
export function ButtonTooltip({ title, shortcut, ...props }) {
shortcut = humanReadableShortcut(shortcut, 1); // show only primary shortcut
@@ -38,15 +39,16 @@ export default function EditorControl({
saveButtonProps,
executeButtonProps,
autocompleteToggleProps,
autoLimitCheckboxProps,
dataSourceSelectorProps,
}) {
useEffect(() => {
const buttons = filter(
[addParameterButtonProps, formatButtonProps, saveButtonProps, executeButtonProps],
b => b.shortcut && !b.disabled && isFunction(b.onClick)
b => b.shortcut && isFunction(b.onClick)
);
if (buttons.length > 0) {
const shortcuts = fromPairs(map(buttons, b => [b.shortcut, b.onClick]));
const shortcuts = fromPairs(map(buttons, b => [b.shortcut, b.disabled ? noop : b.onClick]));
KeyboardShortcuts.bind(shortcuts);
return () => {
KeyboardShortcuts.unbind(shortcuts);
@@ -84,6 +86,7 @@ export default function EditorControl({
onToggle={autocompleteToggleProps.onToggle}
/>
)}
{autoLimitCheckboxProps !== false && <AutoLimitCheckbox {...autoLimitCheckboxProps} />}
{dataSourceSelectorProps === false && <span className="query-editor-controls-spacer" />}
{dataSourceSelectorProps !== false && (
<Select
@@ -153,6 +156,10 @@ EditorControl.propTypes = {
onToggle: PropTypes.func,
}),
]),
autoLimitCheckboxProps: PropTypes.oneOfType([
PropTypes.bool, // `false` to hide
PropTypes.shape(AutoLimitCheckbox.propTypes),
]),
dataSourceSelectorProps: PropTypes.oneOfType([
PropTypes.bool, // `false` to hide
PropTypes.shape({
@@ -175,5 +182,6 @@ EditorControl.defaultProps = {
saveButtonProps: false,
executeButtonProps: false,
autocompleteToggleProps: false,
autoLimitCheckboxProps: false,
dataSourceSelectorProps: false,
};

View File

@@ -21,6 +21,12 @@
}
}
.query-editor-controls-checkbox {
display: inline-block;
white-space: nowrap;
margin: auto 5px;
}
.query-editor-controls-spacer {
flex: 1 1 auto;
height: 35px; // same as Antd <Select>

View File

@@ -1,14 +1,14 @@
import { isNil, map } from "lodash";
import { capitalize, isNil, map, get } from "lodash";
import AceEditor from "react-ace";
import ace from "brace";
import ace from "ace-builds";
import "brace/ext/language_tools";
import "brace/mode/json";
import "brace/mode/python";
import "brace/mode/sql";
import "brace/mode/yaml";
import "brace/theme/textmate";
import "brace/ext/searchbox";
import "ace-builds/src-noconflict/ext-language_tools";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/mode-python";
import "ace-builds/src-noconflict/mode-sql";
import "ace-builds/src-noconflict/mode-yaml";
import "ace-builds/src-noconflict/theme-textmate";
import "ace-builds/src-noconflict/ext-searchbox";
const langTools = ace.acequire("ace/ext/language_tools");
const snippetsModule = ace.acequire("ace/snippets");
@@ -30,13 +30,12 @@ defineDummySnippets("yaml");
function buildTableColumnKeywords(table) {
const keywords = [];
table.columns.forEach(column => {
const columnName = get(column, "name");
keywords.push({
caption: column,
name: `${table.name}.${column}`,
value: `${table.name}.${column}`,
name: `${table.name}.${columnName}`,
value: `${table.name}.${columnName}`,
score: 100,
meta: "Column",
className: "completion",
meta: capitalize(get(column, "type", "Column")),
});
});
return keywords;
@@ -56,7 +55,8 @@ function buildKeywordsFromSchema(schema) {
});
tableColumnKeywords[table.name] = buildTableColumnKeywords(table);
table.columns.forEach(c => {
columnKeywords[c] = "Column";
const columnName = get(c, "name", c);
columnKeywords[columnName] = capitalize(get(c, "type", "Column"));
});
});

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState, useCallback, useImperativeHandle }
import PropTypes from "prop-types";
import cx from "classnames";
import { AceEditor, snippetsModule, updateSchemaCompleter } from "./ace";
import { SchemaItemType } from "@/components/queries/SchemaBrowser";
import resizeObserver from "@/services/resizeObserver";
import QuerySnippet from "@/services/query-snippet";
@@ -157,13 +158,7 @@ QueryEditor.propTypes = {
syntax: PropTypes.string,
value: PropTypes.string,
autocompleteEnabled: PropTypes.bool,
schema: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
size: PropTypes.number,
columns: PropTypes.arrayOf(PropTypes.string).isRequired,
})
),
schema: PropTypes.arrayOf(SchemaItemType),
onChange: PropTypes.func,
onSelectionChange: PropTypes.func,
};

View File

@@ -210,7 +210,7 @@ class ScheduleDialog extends React.Component {
{Object.keys(this.intervals).map(int => (
<OptGroup label={capitalize(pluralize(int))} key={int}>
{this.intervals[int].map(([cnt, secs]) => (
<Option value={secs} key={cnt}>
<Option value={secs} key={`${int}-${cnt}`}>
{durationHumanize(secs)}
</Option>
))}

View File

@@ -120,27 +120,36 @@ describe("ScheduleDialog", () => {
expect(utc.exists()).toBeFalsy();
});
test("onChange correct result", () => {
// Disabling this test as the TimePicker wasn't setting values from here after Antd v4
// eslint-disable-next-line jest/no-disabled-tests
test.skip("onChange correct result", () => {
const onChangeCb = jest.fn(time => time.format("HH:mm"));
const editor = mount(<TimeEditor onChange={onChangeCb} />);
// click TimePicker
editor.find(".ant-time-picker-input").simulate("click");
editor.find(".ant-picker-input input").simulate("mouseDown");
const timePickerPanel = editor.find(".ant-picker-panel");
// select hour "07"
const hourSelector = editor.find(".ant-time-picker-panel-select").at(0);
const hourSelector = timePickerPanel.find(".ant-picker-time-panel-column").at(0);
hourSelector
.find("li")
.at(7)
.simulate("click");
// select minute "30"
const minuteSelector = editor.find(".ant-time-picker-panel-select").at(1);
const minuteSelector = timePickerPanel.find(".ant-picker-time-panel-column").at(1);
minuteSelector
.find("li")
.at(6)
.simulate("click");
timePickerPanel
.find(".ant-picker-ok")
.find("button")
.simulate("mouseDown");
// expect utc to be 2h below initial time
const utc = findByTestID(editor, "utc");
expect(utc.text()).toBe("(05:30 UTC)");
@@ -213,7 +222,7 @@ describe("ScheduleDialog", () => {
.find("Trigger")
.instance()
.getComponent()
).find("MenuItem");
).find(".ant-select-item-option-content");
const texts = options.map(node => node.text());
const expected = ["Never", "1 minute", "5 minutes", "1 hour", "2 hours"];

View File

@@ -51,7 +51,7 @@ export default class SchedulePhrase extends React.Component {
const content = full ? <Tooltip title={full}>{short}</Tooltip> : short;
return this.props.isLink ? (
<a className="schedule-phrase" onClick={this.props.onClick}>
<a className="schedule-phrase" onClick={this.props.onClick} data-test="EditSchedule">
{content}
</a>
) : (

View File

@@ -1,4 +1,5 @@
import { isNil, map, filter, some, includes } from "lodash";
import { isNil, map, filter, some, includes, get } from "lodash";
import cx from "classnames";
import React, { useState, useCallback, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import { useDebouncedCallback } from "use-debounce";
@@ -7,11 +8,20 @@ import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip";
import AutoSizer from "react-virtualized/dist/commonjs/AutoSizer";
import List from "react-virtualized/dist/commonjs/List";
import useDataSourceSchema from "@/pages/queries/hooks/useDataSourceSchema";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import LoadingState from "../items-list/components/LoadingState";
const SchemaItemType = PropTypes.shape({
const SchemaItemColumnType = PropTypes.shape({
name: PropTypes.string.isRequired,
type: PropTypes.string,
});
export const SchemaItemType = PropTypes.shape({
name: PropTypes.string.isRequired,
size: PropTypes.number,
columns: PropTypes.arrayOf(PropTypes.string).isRequired,
loading: PropTypes.bool,
columns: PropTypes.arrayOf(SchemaItemColumnType).isRequired,
});
const schemaTableHeight = 22;
@@ -47,16 +57,24 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
</div>
{expanded && (
<div>
{map(item.columns, column => (
<div key={column} className="table-open">
{column}
<i
className="fa fa-angle-double-right copy-to-editor"
aria-hidden="true"
onClick={e => handleSelect(e, column)}
/>
</div>
))}
{item.loading ? (
<div className="table-open">Loading...</div>
) : (
map(item.columns, column => {
const columnName = get(column, "name");
const columnType = get(column, "type");
return (
<div key={columnName} className="table-open">
{columnName} {columnType && <span className="column-type">{columnType}</span>}
<i
className="fa fa-angle-double-right copy-to-editor"
aria-hidden="true"
onClick={e => handleSelect(e, columnName)}
/>
</div>
);
})
)}
</div>
)}
</div>
@@ -77,7 +95,62 @@ SchemaItem.defaultProps = {
onSelect: () => {},
};
function applyFilter(schema, filterString) {
function SchemaLoadingState() {
return (
<div className="schema-loading-state">
<LoadingState className="" />
</div>
);
}
export function SchemaList({ loading, schema, expandedFlags, onTableExpand, onItemSelect }) {
const [listRef, setListRef] = useState(null);
useEffect(() => {
if (listRef) {
listRef.recomputeRowHeights();
}
}, [listRef, schema, expandedFlags]);
return (
<div className="schema-browser">
{loading && <SchemaLoadingState />}
{!loading && (
<AutoSizer>
{({ width, height }) => (
<List
ref={setListRef}
width={width}
height={height}
rowCount={schema.length}
rowHeight={({ index }) => {
const item = schema[index];
const columnsLength = !item.loading ? item.columns.length : 1;
let columnCount = expandedFlags[item.name] ? columnsLength : 0;
return schemaTableHeight + schemaColumnHeight * columnCount;
}}
rowRenderer={({ key, index, style }) => {
const item = schema[index];
return (
<SchemaItem
key={key}
style={style}
item={item}
expanded={expandedFlags[item.name]}
onToggle={() => onTableExpand(item.name)}
onSelect={onItemSelect}
/>
);
}}
/>
)}
</AutoSizer>
)}
</div>
);
}
export function applyFilterOnSchema(schema, filterString) {
const filters = filter(filterString.toLowerCase().split(/\s+/), s => s.length > 0);
// Empty string: return original schema
@@ -93,7 +166,7 @@ function applyFilter(schema, filterString) {
schema,
item =>
includes(item.name.toLowerCase(), nameFilter) ||
some(item.columns, column => includes(column.toLowerCase(), columnFilter))
some(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter))
);
}
@@ -103,31 +176,38 @@ function applyFilter(schema, filterString) {
return filter(
map(schema, item => {
if (includes(item.name.toLowerCase(), nameFilter)) {
item = { ...item, columns: filter(item.columns, column => includes(column.toLowerCase(), columnFilter)) };
item = {
...item,
columns: filter(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter)),
};
return item.columns.length > 0 ? item : null;
}
})
);
}
export default function SchemaBrowser({ schema, onRefresh, onItemSelect, ...props }) {
export default function SchemaBrowser({
dataSource,
onSchemaUpdate,
onItemSelect,
options,
onOptionsUpdate,
...props
}) {
const [schema, isLoading, refreshSchema] = useDataSourceSchema(dataSource);
const [filterString, setFilterString] = useState("");
const filteredSchema = useMemo(() => applyFilter(schema, filterString), [schema, filterString]);
const [expandedFlags, setExpandedFlags] = useState({});
const filteredSchema = useMemo(() => applyFilterOnSchema(schema, filterString), [schema, filterString]);
const [handleFilterChange] = useDebouncedCallback(setFilterString, 500);
const [listRef, setListRef] = useState(null);
const [expandedFlags, setExpandedFlags] = useState({});
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
useEffect(() => {
setExpandedFlags({});
}, [schema]);
handleSchemaUpdate(schema);
}, [schema, handleSchemaUpdate]);
useEffect(() => {
if (listRef) {
listRef.recomputeRowHeights();
}
}, [listRef, filteredSchema, expandedFlags]);
if (schema.length === 0) {
if (schema.length === 0 && !isLoading) {
return null;
}
@@ -149,53 +229,30 @@ export default function SchemaBrowser({ schema, onRefresh, onItemSelect, ...prop
/>
<Tooltip title="Refresh Schema">
<Button onClick={onRefresh}>
<i className="zmdi zmdi-refresh" />
<Button onClick={() => refreshSchema(true)}>
<i className={cx("zmdi zmdi-refresh", { "zmdi-hc-spin": isLoading })} />
</Button>
</Tooltip>
</div>
<div className="schema-browser">
<AutoSizer>
{({ width, height }) => (
<List
ref={setListRef}
width={width}
height={height}
rowCount={filteredSchema.length}
rowHeight={({ index }) => {
const item = filteredSchema[index];
const columnCount = expandedFlags[item.name] ? item.columns.length : 0;
return schemaTableHeight + schemaColumnHeight * columnCount;
}}
rowRenderer={({ key, index, style }) => {
const item = filteredSchema[index];
return (
<SchemaItem
key={key}
style={style}
item={item}
expanded={expandedFlags[item.name]}
onToggle={() => toggleTable(item.name)}
onSelect={onItemSelect}
/>
);
}}
/>
)}
</AutoSizer>
</div>
<SchemaList
loading={isLoading && schema.length === 0}
schema={filteredSchema}
expandedFlags={expandedFlags}
onTableExpand={toggleTable}
onItemSelect={onItemSelect}
/>
</div>
);
}
SchemaBrowser.propTypes = {
schema: PropTypes.arrayOf(SchemaItemType),
onRefresh: PropTypes.func,
dataSource: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onSchemaUpdate: PropTypes.func,
onItemSelect: PropTypes.func,
};
SchemaBrowser.defaultProps = {
schema: [],
onRefresh: () => {},
dataSource: null,
onSchemaUpdate: () => {},
onItemSelect: () => {},
};

View File

@@ -0,0 +1,154 @@
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { filter, includes, get, find } from "lodash";
import PropTypes from "prop-types";
import { useDebouncedCallback } from "use-debounce";
import Button from "antd/lib/button";
import SyncOutlinedIcon from "@ant-design/icons/SyncOutlined";
import Input from "antd/lib/input";
import Select from "antd/lib/select";
import Tooltip from "antd/lib/tooltip";
import { SchemaList, applyFilterOnSchema } from "@/components/queries/SchemaBrowser";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import useDatabricksSchema from "./useDatabricksSchema";
import "./DatabricksSchemaBrowser.less";
export default function DatabricksSchemaBrowser({
dataSource,
options,
onOptionsUpdate,
onSchemaUpdate,
onItemSelect,
...props
}) {
const {
databases,
loadingDatabases,
schema,
loadingSchema,
loadTableColumns,
currentDatabaseName,
setCurrentDatabase,
refreshAll,
refreshing,
} = useDatabricksSchema(dataSource, options, onOptionsUpdate);
const [filterString, setFilterString] = useState("");
const [databaseFilterString, setDatabaseFilterString] = useState("");
const filteredSchema = useMemo(() => applyFilterOnSchema(schema, filterString), [schema, filterString]);
const [isDatabaseSelectOpen, setIsDatabaseSelectOpen] = useState(false);
const [expandedFlags, setExpandedFlags] = useState({});
const [handleFilterChange] = useDebouncedCallback(setFilterString, 500);
const [handleDatabaseFilterChange, cancelHandleDatabaseFilterChange] = useDebouncedCallback(
setDatabaseFilterString,
500
);
const handleDatabaseSelection = useCallback(
databaseName => {
setCurrentDatabase(databaseName);
cancelHandleDatabaseFilterChange();
setDatabaseFilterString("");
},
[cancelHandleDatabaseFilterChange, setCurrentDatabase]
);
const filteredDatabases = useMemo(
() => filter(databases, database => includes(database.toLowerCase(), databaseFilterString.toLowerCase())),
[databases, databaseFilterString]
);
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
useEffect(() => {
handleSchemaUpdate(schema);
}, [schema, handleSchemaUpdate]);
useEffect(() => {
setExpandedFlags({});
}, [currentDatabaseName]);
if (schema.length === 0 && databases.length === 0 && !(loadingDatabases || loadingSchema)) {
return null;
}
function toggleTable(tableName) {
const table = find(schema, { name: tableName });
if (!expandedFlags[tableName] && get(table, "loading", false)) {
loadTableColumns(tableName);
}
setExpandedFlags({
...expandedFlags,
[tableName]: !expandedFlags[tableName],
});
}
return (
<div className="databricks-schema-browser schema-container" {...props}>
<div className="schema-control">
<Input
className={isDatabaseSelectOpen ? "database-select-open" : ""}
placeholder="Filter tables & columns..."
disabled={loadingDatabases || loadingSchema}
onChange={event => handleFilterChange(event.target.value)}
addonBefore={
<Select
dropdownClassName="databricks-schema-browser-db-dropdown"
loading={loadingDatabases}
disabled={loadingDatabases}
onChange={handleDatabaseSelection}
value={currentDatabaseName}
showSearch
onSearch={handleDatabaseFilterChange}
onDropdownVisibleChange={setIsDatabaseSelectOpen}
placeholder={
<>
<i className="fa fa-database m-r-5" /> Database
</>
}>
{filteredDatabases.map(database => (
<Select.Option key={database}>
<i className="fa fa-database m-r-5" />
{database}
</Select.Option>
))}
</Select>
}
/>
</div>
<div className="schema-list-wrapper">
<SchemaList
loading={loadingDatabases || loadingSchema}
schema={filteredSchema}
expandedFlags={expandedFlags}
onTableExpand={toggleTable}
onItemSelect={onItemSelect}
/>
{!(loadingSchema || loadingDatabases) && (
<div className="load-button">
<Tooltip title={!refreshing ? "Refresh Databases and Current Schema" : null}>
<Button type="link" onClick={refreshAll} disabled={refreshing}>
<SyncOutlinedIcon spin={refreshing} />
</Button>
</Tooltip>
</div>
)}
</div>
</div>
);
}
DatabricksSchemaBrowser.propTypes = {
dataSource: PropTypes.object, // eslint-disable-line react/forbid-prop-types
options: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onOptionsUpdate: PropTypes.func,
onSchemaUpdate: PropTypes.func,
onItemSelect: PropTypes.func,
};
DatabricksSchemaBrowser.defaultProps = {
dataSource: null,
options: null,
onOptionsUpdate: () => {},
onSchemaUpdate: () => {},
onItemSelect: () => {},
};

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