Compare commits

...

91 Commits

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

* Simplified approach

* Ran prettier 😬

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

* General Settings updates

* Dynamic Date[Range] updates

* EmptyState updates

* Query and SchemaBrowser updates

* Adjust page headers and add disablePublish

* Policy updates

* Separate Home FavoritesList component

* Update FormatQuery

* Autolimit frontend fixes

* Misc updates

* Keep registering of QuerySourceDropdown

* Undo changes in DynamicComponent

* Change sql-formatter package.json syntax

* Allow opening help trigger in new tab

* Don't run npm commands as root in Dockerfile

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

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

* Use Heroku worker as the BaseWorker

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

* Fix imports based upon review

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

* removed unused import

* improved calculation of item percentile

* added getItemOfPercentileLength to relevant spots

* added getItemOfPercentileLength to relevant spots

* Added missing import

* created custom select element

* added check for property path

* removed uses of percentile util

* gave up on getting element reference

* finished testing Select component

* removed unused imports

* removed older uses of Option component

* added canvas calculation

* removed minWidth from Select

* improved calculation

* added fallbacks

* added estimated offset

* removed leftovers 😅

* replaced to percentiles to max value

* switched to memo and renamed component

* proper useMemo syntax

* Update client/app/components/Select.tsx

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

* created custom restrictive types

* added quick const

* fixed style

* fixed generics

* added pos absolute to fix percy

* removed custom select from ParameterMappingInput

* applied prettier

* Revert "added pos absolute to fix percy"

This reverts commit 4daf1d4bef.

* Pin Percy version to 0.24.3

* Update client/app/components/ParameterMappingInput.jsx

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

* renamed Select.jsx to SelectWithVirtualScroll

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

* removed required from input

* Revert "removed required from input"

This reverts commit b56cd76fa1.

* Redo "removed required from input"

* removed typo

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

* Add changes to use inline metadata.

* add switch for static and dynamic SAML configurations

* Fixed config of backend static/dynamic to match UI

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

* remove print debug statement

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

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

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

* add logging for entityid for validation

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

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

* Incorporate SAML type with Enabled setting

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

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

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

* added x/y manipulation

* replaced x/y management to inner series preparer

* added tests

* moved axis inversion to all charts series

* removed line and area

* inverted labels ui

* removed normalizer check, simplified inverted axes check

* finished working hbar

* minor review

* added conditional title to YAxis

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

* fixed updates

* fixed updates to layout

* fixed minor issues

* removed right Y axis when axes inverted

* ran prettier

* fixed updater function conflict and misuse of getOptions

* renamed inverted to swapped

* created mappingtypes for swapped columns

* removed unused import

* minor polishing

* improved series behaviour in h-bar

* minor fix

* added basic filter to ChartTypeSelect

* final setup of filtered chart types

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

* added proptypes and renamed ChartTypeSelect props

* Add missing import

* fixed import, moved result array to global scope

* merged import

* clearer naming in ChartTypeSelect

* better lodash map syntax

* fixed global modification

* moved result inside useMemo

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

* Convert Queries List page to functional component

* Convert Dashboards List page to functional component

* Extra actions for Query List page

* Extra actions for Dashboard List page

* Extra actions for Dashboard page

* Pass some extra data to Dashboard.HeaderExtra component

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

* Updated cypress image

* Fixed failing tests

* Updated NODE_VERSION in netlify

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

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

* fixed test in choropleth

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

* Skip Puppeteer Chromium as well

* Put back missing npm install on netlify.toml

* Netlify: move env vars to build.environment

* Remove cypress:install script

* Update Cypress dockerfile

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

* Fix typo (with @deecay)

* Add alignYAxesAtZero function

* Avoid 0 division

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

* Use cache for geoJson requests

* Don't handle bounds changes while loading geoJson data

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

* Improve cache

* Optimize Japan Perfectures map (remove irrelevant GeoJson properties)

* Improve getOptions for Choropleth; remove unused code

* Fix test

* Add US states map

* Convert USA map to Albers projection

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

* Update implementation

* Update implementation

* Minor fix

* Update modal

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

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

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

* add axios-auth-refresh

* retry CSRF-related 400 errors by refreshing the cookie

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

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

* Convert TagsList to typescript

* Allow to unselect all tags

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

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

* Add frontend changes and connect to backend

* Fix query hash because of default limit

* fix CircleCI test

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

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

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

* Move CardsList to typescript

* Convert CardsList component to functional component

* CR1

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

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

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

* Implement statement splitting function and add tests for it

* Add a test for databricks-specific syntax

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

* Use Link component for external links as well

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

* Use Link component in visualizations library

* Simplify Link component implementation

* CR1

* Trigger build

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

* More portable image url.

* durationHumanize: support for milliseconds.

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

* Rewrite services/routes in TypeScript.

* Add TypeScript definitions for DialogComponent.

* Make image paths more portable

* Add current route context and hook.

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

* Rewrite ItemsList in TypeScript.

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

* Rearrange props to be friendly to TypeScript.

* Type definitions for NotificationApi.

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

* URL Escape password in Alembic configuration.

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

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

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

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

* Remove content limit; limit sidebar width

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

* refactor redash-api to a set of Cypress commands

* support mounting Redash endpoints in Cypress routes

* fix some parameter specs by waiting for schema to load

* extract baseUrl from cypress.json

* Restyled by prettier (#5110)

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

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

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

* exempt auth blueprints from CSRF protection

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

* Move styling to a new class

* Update renderer.less

* Move class to table and fix top border

* Update renderer.less

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

Thanks, this change is good to me.

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

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

* add CSRF tokens to all static forms

* add CSRF tokens to all axios requests

* disable CSRF validation in unit tests

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

* don't enfroce CSRF checks by default

* avoid CSRF enforcement in unit tests

* remove redundant spread

* some camel casing hiccups

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

* Restyled by prettier (#5056)

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

* set a CSRF header only if cookie is present

* enforce CSRF in CI

* install lodash directly for Cypress

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

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

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

* Add caching

* Drop allSettled

* Simplify refresh button

* Update error to return 500

* Load tables before loading columns

* Don't mutate schema

* Reset db name and schemas when changing data source

* Load both tables and columns

* Return error with code 200

* Code review updates

* Add expiration time to the cache Keys

* Back with RQ
2020-07-30 15:16:14 +03:00
Levko Kravets
93b4be672f Queries list: move "My Queries" above "Archived" (#5072) 2020-07-28 19:53:22 +03:00
Gabriel Dutra
f396c96457 Merge branch 'master' into query-based-dropdown--parameters 2020-02-25 07:49:36 -03:00
Gabriel Dutra
8bfcbf21e3 Remove redundant import 2020-02-24 11:44:28 -03:00
Gabriel Dutra
8a1640c4e7 Separate InputPopover component 2020-02-24 11:44:18 -03:00
Gabriel Dutra
a37e7f93dc Add is_safe test for queries with params 2020-02-22 15:47:29 -03:00
Gabriel Dutra
cc34e781d3 Small updates
- Change searchTerm separator
- Add cy.wait
2020-02-22 15:23:43 -03:00
Gabriel Dutra
6aa0ea715e Invert tooltip messages order 2020-02-22 14:08:19 -03:00
Gabriel Dutra
6c27619671 Make Parameter Mapping required in UI 2020-02-21 23:00:26 -03:00
Gabriel Dutra
6eeb3b3eb2 Separate UI components 2020-02-21 15:49:23 -03:00
Gabriel Dutra
d40edb81c2 Fix backend tests 2020-02-21 14:18:59 -03:00
Gabriel Dutra
f128b4b85f Only allow search for Text Parameters 2020-02-21 13:36:06 -03:00
Gabriel Dutra
264fb5798d Merge branch 'master' into query-based-dropdown--parameters 2020-02-21 13:31:49 -03:00
Gabriel Dutra
90023ac435 Make sure Table updates correctly 2020-02-21 11:03:37 -03:00
Gabriel Dutra
df755fbc17 Add try except for NoResultFound 2020-02-21 09:40:52 -03:00
Gabriel Dutra
e555642844 Add is_safe check for parameterized query based 2020-02-21 09:27:12 -03:00
Gabriel Dutra
bdd7b146ae Change stored mapping attributes 2020-02-20 21:59:19 -03:00
Gabriel Dutra
b7478defec Don't validade query params with params 2020-02-20 19:29:43 -03:00
Gabriel Dutra
bb0d7830c9 Fixes + temp remove validation for Query param 2020-02-20 18:49:29 -03:00
Gabriel Dutra
137aa22dd4 Parameter Mapping UI (2/2) 2020-02-18 17:55:27 -03:00
Gabriel Dutra
9cf396599a Parameter Mapping UI (1/2) 2020-02-17 23:32:10 -03:00
Gabriel Dutra
b70f0fa921 Iterate over backend verification 2020-02-16 13:39:05 -03:00
Gabriel Dutra
5e3613d6cb Start experiements with a 'search' parameter 2020-02-13 16:29:50 -03:00
347 changed files with 22433 additions and 6960 deletions

View File

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

View File

@@ -57,6 +57,9 @@ jobs:
- store_artifacts: - store_artifacts:
path: coverage.xml path: coverage.xml
frontend-lint: frontend-lint:
environment:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:12 - image: circleci/node:12
steps: steps:
@@ -67,6 +70,9 @@ jobs:
- store_test_results: - store_test_results:
path: /tmp/test-results path: /tmp/test-results
frontend-unit-tests: frontend-unit-tests:
environment:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:12 - image: circleci/node:12
steps: steps:
@@ -90,11 +96,20 @@ jobs:
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA== PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
docker: docker:
- image: circleci/node:12 - image: circleci/node:12
steps: steps:
- setup_remote_docker - setup_remote_docker
- checkout - checkout
- run:
name: Enable Code Coverage report for master branch
command: |
if [ "$CIRCLE_BRANCH" = "master" ]; then
echo 'export CODE_COVERAGE=true' >> $BASH_ENV
source $BASH_ENV
fi
- run: - run:
name: Install npm dependencies name: Install npm dependencies
command: | command: |
@@ -113,6 +128,13 @@ jobs:
command: | command: |
docker-compose logs docker-compose logs
when: on_fail when: on_fail
- run:
name: Copy Code Coverage results
command: |
docker cp cypress:/usr/src/app/coverage ./coverage || true
when: always
- store_artifacts:
path: coverage
build-docker-image: *build-docker-image-job build-docker-image: *build-docker-image-job
build-preview-docker-image: *build-docker-image-job build-preview-docker-image: *build-docker-image-job
workflows: workflows:

View File

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

View File

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

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@ venv/
.coveralls.yml .coveralls.yml
.idea .idea
*.pyc *.pyc
.nyc_output
coverage
.coverage .coverage
coverage.xml coverage.xml
client/dist client/dist

View File

@@ -3,13 +3,24 @@ FROM node:12 as frontend-builder
# Controls whether to build the frontend assets # Controls whether to build the frontend assets
ARG skip_frontend_build ARG skip_frontend_build
ENV CYPRESS_INSTALL_BINARY=0
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
RUN useradd -m -d /frontend redash
USER redash
WORKDIR /frontend WORKDIR /frontend
COPY package.json package-lock.json /frontend/ COPY --chown=redash package.json package-lock.json /frontend/
COPY viz-lib /frontend/viz-lib COPY --chown=redash viz-lib /frontend/viz-lib
# Controls whether to instrument code for coverage information
ARG code_coverage
ENV BABEL_ENV=${code_coverage:+test}
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi RUN if [ "x$skip_frontend_build" = "x" ] ; then npm ci --unsafe-perm; fi
COPY client /frontend/client COPY --chown=redash client /frontend/client
COPY webpack.config.js /frontend/ COPY --chown=redash webpack.config.js /frontend/
RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi RUN if [ "x$skip_frontend_build" = "x" ] ; then npm run build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
FROM python:3.7-slim FROM python:3.7-slim

View File

@@ -35,7 +35,7 @@ backend-unit-tests: up test_db
docker-compose run --rm --name tests server tests docker-compose run --rm --name tests server tests
frontend-unit-tests: bundle frontend-unit-tests: bundle
npm ci CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 npm ci
npm run bundle npm run bundle
npm test npm test

View File

@@ -19,7 +19,7 @@ worker() {
export WORKERS_COUNT=${WORKERS_COUNT:-2} export WORKERS_COUNT=${WORKERS_COUNT:-2}
export QUEUES=${QUEUES:-} export QUEUES=${QUEUES:-}
supervisord -c worker.conf exec supervisord -c worker.conf
} }
dev_worker() { dev_worker() {

View File

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

View File

@@ -20,6 +20,21 @@ module.exports = {
// allow debugger during development // allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0, "no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
"jsx-a11y/anchor-is-valid": "off", "jsx-a11y/anchor-is-valid": "off",
"no-restricted-imports": [
"error",
{
paths: [
{
name: "antd",
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
},
{
name: "antd/lib",
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
},
],
},
],
}, },
overrides: [ overrides: [
{ {
@@ -34,6 +49,8 @@ module.exports = {
// Do not complain about useless contructors in declaration files // Do not complain about useless contructors in declaration files
"no-useless-constructor": "off", "no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": "error", "@typescript-eslint/no-useless-constructor": "error",
// Many API fields and generated types use camelcase
"@typescript-eslint/camelcase": "off",
}, },
}, },
], ],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -16,7 +16,6 @@
@import "~antd/lib/pagination/style/index"; @import "~antd/lib/pagination/style/index";
@import "~antd/lib/table/style/index"; @import "~antd/lib/table/style/index";
@import "~antd/lib/popover/style/index"; @import "~antd/lib/popover/style/index";
@import "~antd/lib/icon/style/index";
@import "~antd/lib/tag/style/index"; @import "~antd/lib/tag/style/index";
@import "~antd/lib/grid/style/index"; @import "~antd/lib/grid/style/index";
@import "~antd/lib/switch/style/index"; @import "~antd/lib/switch/style/index";
@@ -31,6 +30,7 @@
@import "~antd/lib/badge/style/index"; @import "~antd/lib/badge/style/index";
@import "~antd/lib/card/style/index"; @import "~antd/lib/card/style/index";
@import "~antd/lib/spin/style/index"; @import "~antd/lib/spin/style/index";
@import "~antd/lib/skeleton/style/index";
@import "~antd/lib/tabs/style/index"; @import "~antd/lib/tabs/style/index";
@import "~antd/lib/notification/style/index"; @import "~antd/lib/notification/style/index";
@import "~antd/lib/collapse/style/index"; @import "~antd/lib/collapse/style/index";
@@ -401,3 +401,14 @@
.@{checkbox-prefix-cls} + span { .@{checkbox-prefix-cls} + span {
padding-right: 0; padding-right: 0;
} }
// make sure Multiple select has room for icons
.@{select-prefix-cls}-multiple {
&.@{select-prefix-cls}-show-arrow,
&.@{select-prefix-cls}-show-search,
&.@{select-prefix-cls}-loading {
.@{select-prefix-cls}-selector {
padding-right: 30px;
}
}
}

View File

@@ -23,6 +23,10 @@
padding: 5px 8px; padding: 5px 8px;
} }
.ant-form-item-explain {
margin-top: 10px;
}
.alert-last-triggered { .alert-last-triggered {
color: @headings-color; color: @headings-color;
} }

View File

@@ -78,8 +78,6 @@ strong {
} }
} }
// Fixed width layout for specific pages
.settings-screen, .settings-screen,
.home-page, .home-page,
.page-dashboard-list, .page-dashboard-list,
@@ -89,7 +87,7 @@ strong {
.admin-page-layout { .admin-page-layout {
.container { .container {
width: 100%; width: 100%;
max-width: 1200px; max-width: none;
} }
} }

View File

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

View File

@@ -141,6 +141,7 @@ a.label-tag {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
position: relative;
} }
.query-fullscreen { .query-fullscreen {

View File

@@ -2,13 +2,22 @@ import { first } from "lodash";
import React, { useState } from "react"; import React, { useState } from "react";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Icon from "antd/lib/icon"; import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog"; import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { Auth, currentUser } from "@/services/auth"; import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png"; 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 VersionInfo from "./VersionInfo";
import "./DesktopNavbar.less"; import "./DesktopNavbar.less";
@@ -37,34 +46,36 @@ export default function DesktopNavbar() {
return ( return (
<div className="desktop-navbar"> <div className="desktop-navbar">
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo"> <NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
<a href="./"> <div>
<Link href="./">
<img src={logoUrl} alt="Redash" /> <img src={logoUrl} alt="Redash" />
</a> </Link>
</div>
</NavbarSection> </NavbarSection>
<NavbarSection inlineCollapsed={collapsed}> <NavbarSection inlineCollapsed={collapsed}>
{currentUser.hasPermission("list_dashboards") && ( {currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards"> <Menu.Item key="dashboards">
<a href="dashboards"> <Link href="dashboards">
<Icon type="desktop" /> <DesktopOutlinedIcon />
<span>Dashboards</span> <span>Dashboards</span>
</a> </Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("view_query") && ( {currentUser.hasPermission("view_query") && (
<Menu.Item key="queries"> <Menu.Item key="queries">
<a href="queries"> <Link href="queries">
<Icon type="code" /> <CodeOutlinedIcon />
<span>Queries</span> <span>Queries</span>
</a> </Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("list_alerts") && ( {currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts"> <Menu.Item key="alerts">
<a href="alerts"> <Link href="alerts">
<Icon type="alert" /> <AlertOutlinedIcon />
<span>Alerts</span> <span>Alerts</span>
</a> </Link>
</Menu.Item> </Menu.Item>
)} )}
</NavbarSection> </NavbarSection>
@@ -78,16 +89,16 @@ export default function DesktopNavbar() {
title={ title={
<React.Fragment> <React.Fragment>
<span data-test="CreateButton"> <span data-test="CreateButton">
<Icon type="plus" /> <PlusOutlinedIcon />
<span>Create</span> <span>Create</span>
</span> </span>
</React.Fragment> </React.Fragment>
}> }>
{canCreateQuery && ( {canCreateQuery && (
<Menu.Item key="new-query"> <Menu.Item key="new-query">
<a href="queries/new" data-test="CreateQueryMenuItem"> <Link href="queries/new" data-test="CreateQueryMenuItem">
New Query New Query
</a> </Link>
</Menu.Item> </Menu.Item>
)} )}
{canCreateDashboard && ( {canCreateDashboard && (
@@ -99,9 +110,9 @@ export default function DesktopNavbar() {
)} )}
{canCreateAlert && ( {canCreateAlert && (
<Menu.Item key="new-alert"> <Menu.Item key="new-alert">
<a data-test="CreateAlertMenuItem" href="alerts/new"> <Link data-test="CreateAlertMenuItem" href="alerts/new">
New Alert New Alert
</a> </Link>
</Menu.Item> </Menu.Item>
)} )}
</Menu.SubMenu> </Menu.SubMenu>
@@ -111,16 +122,16 @@ export default function DesktopNavbar() {
<NavbarSection inlineCollapsed={collapsed}> <NavbarSection inlineCollapsed={collapsed}>
<Menu.Item key="help"> <Menu.Item key="help">
<HelpTrigger showTooltip={false} type="HOME"> <HelpTrigger showTooltip={false} type="HOME">
<Icon type="question-circle" /> <QuestionCircleOutlinedIcon />
<span>Help</span> <span>Help</span>
</HelpTrigger> </HelpTrigger>
</Menu.Item> </Menu.Item>
{firstSettingsTab && ( {firstSettingsTab && (
<Menu.Item key="settings"> <Menu.Item key="settings">
<a href={firstSettingsTab.path} data-test="SettingsLink"> <Link href={firstSettingsTab.path} data-test="SettingsLink">
<Icon type="setting" /> <SettingOutlinedIcon />
<span>Settings</span> <span>Settings</span>
</a> </Link>
</Menu.Item> </Menu.Item>
)} )}
<Menu.Divider /> <Menu.Divider />
@@ -137,11 +148,11 @@ export default function DesktopNavbar() {
</span> </span>
}> }>
<Menu.Item key="profile"> <Menu.Item key="profile">
<a href="users/me">Profile</a> <Link href="users/me">Profile</Link>
</Menu.Item> </Menu.Item>
{currentUser.hasPermission("super_admin") && ( {currentUser.hasPermission("super_admin") && (
<Menu.Item key="status"> <Menu.Item key="status">
<a href="admin/status">System Status</a> <Link href="admin/status">System Status</Link>
</Menu.Item> </Menu.Item>
)} )}
<Menu.Divider /> <Menu.Divider />
@@ -158,7 +169,7 @@ export default function DesktopNavbar() {
</NavbarSection> </NavbarSection>
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button"> <Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
<Icon type={collapsed ? "menu-unfold" : "menu-fold"} /> {collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
</Button> </Button>
</div> </div>
); );

View File

@@ -2,9 +2,10 @@ import { first } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Icon from "antd/lib/icon"; import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
import Dropdown from "antd/lib/dropdown"; import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import { Auth, currentUser } from "@/services/auth"; import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png"; import logoUrl from "@/assets/images/redash_icon_small.png";
@@ -17,9 +18,9 @@ export default function MobileNavbar({ getPopupContainer }) {
return ( return (
<div className="mobile-navbar"> <div className="mobile-navbar">
<div className="mobile-navbar-logo"> <div className="mobile-navbar-logo">
<a href="./"> <Link href="./">
<img src={logoUrl} alt="Redash" /> <img src={logoUrl} alt="Redash" />
</a> </Link>
</div> </div>
<div> <div>
<Dropdown <Dropdown
@@ -30,39 +31,39 @@ export default function MobileNavbar({ getPopupContainer }) {
<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu"> <Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
{currentUser.hasPermission("list_dashboards") && ( {currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards"> <Menu.Item key="dashboards">
<a href="dashboards">Dashboards</a> <Link href="dashboards">Dashboards</Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("view_query") && ( {currentUser.hasPermission("view_query") && (
<Menu.Item key="queries"> <Menu.Item key="queries">
<a href="queries">Queries</a> <Link href="queries">Queries</Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("list_alerts") && ( {currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts"> <Menu.Item key="alerts">
<a href="alerts">Alerts</a> <Link href="alerts">Alerts</Link>
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item key="profile"> <Menu.Item key="profile">
<a href="users/me">Edit Profile</a> <Link href="users/me">Edit Profile</Link>
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
{firstSettingsTab && ( {firstSettingsTab && (
<Menu.Item key="settings"> <Menu.Item key="settings">
<a href={firstSettingsTab.path}>Settings</a> <Link href={firstSettingsTab.path}>Settings</Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("super_admin") && ( {currentUser.hasPermission("super_admin") && (
<Menu.Item key="status"> <Menu.Item key="status">
<a href="admin/status">System Status</a> <Link href="admin/status">System Status</Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("super_admin") && <Menu.Divider />} {currentUser.hasPermission("super_admin") && <Menu.Divider />}
<Menu.Item key="help"> <Menu.Item key="help">
{/* eslint-disable-next-line react/jsx-no-target-blank */} {/* eslint-disable-next-line react/jsx-no-target-blank */}
<a href="https://redash.io/help" target="_blank" rel="noopener"> <Link href="https://redash.io/help" target="_blank" rel="noopener">
Help Help
</a> </Link>
</Menu.Item> </Menu.Item>
<Menu.Item key="logout" onClick={() => Auth.logout()}> <Menu.Item key="logout" onClick={() => Auth.logout()}>
Log out Log out
@@ -70,7 +71,7 @@ export default function MobileNavbar({ getPopupContainer }) {
</Menu> </Menu>
}> }>
<Button className="mobile-navbar-toggle-button" ghost> <Button className="mobile-navbar-toggle-button" ghost>
<Icon type="menu" /> <MenuOutlinedIcon />
</Button> </Button>
</Dropdown> </Dropdown>
</div> </div>

View File

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

View File

@@ -13,6 +13,7 @@ export default function ApplicationLayout({ children }) {
return ( return (
<React.Fragment> <React.Fragment>
<DynamicComponent name="ApplicationWrapper">
<div className="application-layout-side-menu"> <div className="application-layout-side-menu">
<DynamicComponent name="ApplicationDesktopNavbar"> <DynamicComponent name="ApplicationDesktopNavbar">
<DesktopNavbar /> <DesktopNavbar />
@@ -26,6 +27,7 @@ export default function ApplicationLayout({ children }) {
</nav> </nav>
{children} {children}
</div> </div>
</DynamicComponent>
</React.Fragment> </React.Fragment>
); );
} }

View File

@@ -1,8 +1,10 @@
import { isObject, get } from "lodash"; import { get, isObject } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import "./ErrorMessage.less"; import "./ErrorMessage.less";
import DynamicComponent from "@/components/DynamicComponent";
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
function getErrorMessageByStatus(status, defaultMessage) { function getErrorMessageByStatus(status, defaultMessage) {
switch (status) { switch (status) {
@@ -31,21 +33,30 @@ function getErrorMessage(error) {
return message; return message;
} }
export default function ErrorMessage({ error }) { export default function ErrorMessage({ error, message }) {
if (!error) { if (!error) {
return null; return null;
} }
console.error(error); console.error(error);
const errorDetailsProps = {
error,
message: message || getErrorMessage(error),
};
return ( return (
<div className="error-message-container" data-test="ErrorMessage"> <div className="error-message-container" data-test="ErrorMessage" role="alert">
<div className="error-state bg-white tiled"> <div className="error-state bg-white tiled">
<div className="error-state__icon"> <div className="error-state__icon">
<i className="zmdi zmdi-alert-circle-o" /> <i className="zmdi zmdi-alert-circle-o" />
</div> </div>
<div className="error-state__details"> <div className="error-state__details">
<h4>{getErrorMessage(error)}</h4> <DynamicComponent
name="ErrorMessageDetails"
fallback={<ErrorMessageDetails {...errorDetailsProps} />}
{...errorDetailsProps}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -54,4 +65,5 @@ export default function ErrorMessage({ error }) {
ErrorMessage.propTypes = { ErrorMessage.propTypes = {
error: PropTypes.object.isRequired, error: PropTypes.object.isRequired,
message: PropTypes.string,
}; };

View File

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

View File

@@ -1,5 +1,5 @@
import { isFunction, startsWith, trimStart, trimEnd } from "lodash"; import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef, useContext } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import UniversalRouter from "universal-router"; import UniversalRouter from "universal-router";
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary"; import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
@@ -14,6 +14,12 @@ function generateRouteKey() {
.substr(2); .substr(2);
} }
export const CurrentRouteContext = React.createContext(null);
export function useCurrentRoute() {
return useContext(CurrentRouteContext);
}
export function stripBase(href) { export function stripBase(href) {
// Resolve provided link and '' (root) relative to document's base. // Resolve provided link and '' (root) relative to document's base.
// If provided href is not related to current document (does not // If provided href is not related to current document (does not
@@ -53,7 +59,7 @@ export default function Router({ routes, onRouteChange }) {
errorHandlerRef.current.reset(); errorHandlerRef.current.reset();
} }
const pathname = stripBase(location.path); const pathname = stripBase(location.path) || "/";
// This is a optimization for route resolver: if current route was already resolved // This is a optimization for route resolver: if current route was already resolved
// from this path - do nothing. It also prevents router from using outdated route in a case // from this path - do nothing. It also prevents router from using outdated route in a case
@@ -109,9 +115,11 @@ export default function Router({ routes, onRouteChange }) {
} }
return ( return (
<CurrentRouteContext.Provider value={currentRoute}>
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}> <ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
{currentRoute.render(currentRoute)} {currentRoute.render(currentRoute)}
</ErrorBoundary> </ErrorBoundary>
</CurrentRouteContext.Provider>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { isEqual, isEmpty } from "lodash"; import { isEqual, isEmpty, map } from "lodash";
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Select from "antd/lib/select"; import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number"; import InputNumber from "antd/lib/input-number";
import DateParameter from "@/components/dynamic-parameters/DateParameter"; import DateParameter from "@/components/dynamic-parameters/DateParameter";
@@ -10,8 +10,6 @@ import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less"; import "./ParameterValueInput.less";
const { Option } = Select;
const multipleValuesProps = { const multipleValuesProps = {
maxTagCount: 3, maxTagCount: 3,
maxTagTextLength: 10, maxTagTextLength: 10,
@@ -98,25 +96,20 @@ class ParameterValueInput extends React.Component {
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== ""); const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
// Antd Select doesn't handle null in multiple mode // Antd Select doesn't handle null in multiple mode
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val); const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
return ( return (
<Select <SelectWithVirtualScroll
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? "multiple" : "default"} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children" optionFilterProp="children"
value={normalize(value)} value={normalize(value)}
onChange={this.onSelect} onChange={this.onSelect}
dropdownMatchSelectWidth={false} options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
showSearch showSearch
showArrow showArrow
style={{ minWidth: 60 }}
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null} notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
{...multipleValuesProps}> {...multipleValuesProps}
{enumOptionsArray.map(option => ( />
<Option key={option} value={option}>
{option}
</Option>
))}
</Select>
); );
} }

View File

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

View File

@@ -7,7 +7,6 @@ import { Parameter, createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton"; import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput"; import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog"; import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/lib/utils";
import "./Parameters.less"; import "./Parameters.less";
@@ -121,7 +120,7 @@ export default class Parameters extends React.Component {
return ( return (
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}> <div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
<div className="parameter-heading"> <div className="parameter-heading">
<label>{param.title || toHuman(param.name)}</label> <label>{param.getTitle()}</label>
{editable && ( {editable && (
<button <button
className="btn btn-default btn-xs m-l-5" className="btn btn-default btn-xs m-l-5"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import location from "@/services/location"; import location from "@/services/location";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
@@ -17,9 +18,9 @@ function wrapSettingsTab(id, options, WrappedComponent) {
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal"> <Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
{settingsMenu.getAvailableItems().map(item => ( {settingsMenu.getAvailableItems().map(item => (
<Menu.Item key={item.title}> <Menu.Item key={item.title}>
<a href={item.path} data-test="SettingsScreenItem"> <Link href={item.path} data-test="SettingsScreenItem">
{item.title} {item.title}
</a> </Link>
</Menu.Item> </Menu.Item>
))} ))}
</Menu> </Menu>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -238,6 +238,7 @@ class DashboardGrid extends React.Component {
return ( return (
<div className={className}> <div className={className}>
<ResponsiveGridLayout <ResponsiveGridLayout
draggableCancel="input"
className={cx("layout", { "disable-animations": this.state.disableAnimations })} className={cx("layout", { "disable-animations": this.state.disableAnimations })}
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }} cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
rowHeight={cfg.rowHeight - cfg.margins} rowHeight={cfg.rowHeight - cfg.margins}

View File

@@ -3,10 +3,11 @@ import PropTypes from "prop-types";
import Button from "antd/lib/button"; import Button from "antd/lib/button";
import Modal from "antd/lib/modal"; import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { FiltersType } from "@/components/Filters";
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer"; import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
import VisualizationName from "@/components/visualizations/VisualizationName"; import VisualizationName from "@/components/visualizations/VisualizationName";
function ExpandedWidgetDialog({ dialog, widget }) { function ExpandedWidgetDialog({ dialog, widget, filters }) {
return ( return (
<Modal <Modal
{...dialog.props} {...dialog.props}
@@ -20,6 +21,7 @@ function ExpandedWidgetDialog({ dialog, widget }) {
<VisualizationRenderer <VisualizationRenderer
visualization={widget.visualization} visualization={widget.visualization}
queryResult={widget.getQueryResult()} queryResult={widget.getQueryResult()}
filters={filters}
context="widget" context="widget"
/> />
</Modal> </Modal>
@@ -29,6 +31,11 @@ function ExpandedWidgetDialog({ dialog, widget }) {
ExpandedWidgetDialog.propTypes = { ExpandedWidgetDialog.propTypes = {
dialog: DialogPropType.isRequired, dialog: DialogPropType.isRequired,
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
filters: FiltersType,
};
ExpandedWidgetDialog.defaultProps = {
filters: [],
}; };
export default wrapDialog(ExpandedWidgetDialog); export default wrapDialog(ExpandedWidgetDialog);

View File

@@ -7,6 +7,7 @@ import Modal from "antd/lib/modal";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import Tooltip from "antd/lib/tooltip"; import Tooltip from "antd/lib/tooltip";
import Divider from "antd/lib/divider"; import Divider from "antd/lib/divider";
import Link from "@/components/Link";
import HtmlContent from "@redash/viz/lib/components/HtmlContent"; import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import notification from "@/services/notification"; import notification from "@/services/notification";
@@ -78,9 +79,12 @@ function TextboxDialog({ dialog, isNew, ...props }) {
/> />
<small> <small>
Supports basic{" "} 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> <Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
</a> </Link>
. .
</small> </small>
{text && ( {text && (

View File

@@ -48,10 +48,10 @@
top: 0; top: 0;
left: 0; left: 0;
bottom: 85px; bottom: 85px;
right: 15px; right: 0;
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px), background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent); linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: calc((100vw - 15px) / 6) 5px; background-size: calc((100% + 15px) / 6) 5px;
background-position: -7px 1px; background-position: -7px 1px;
} }
} }

View File

@@ -8,6 +8,7 @@ import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { currentUser } from "@/services/auth"; import { currentUser } from "@/services/auth";
import recordEvent from "@/services/recordEvent"; import recordEvent from "@/services/recordEvent";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import Link from "@/components/Link";
import Parameters from "@/components/Parameters"; import Parameters from "@/components/Parameters";
import TimeAgo from "@/components/TimeAgo"; import TimeAgo from "@/components/TimeAgo";
import Timer from "@/components/Timer"; import Timer from "@/components/Timer";
@@ -30,27 +31,27 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
return compact([ return compact([
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}> <Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? ( {!isQueryResultEmpty ? (
<a href={downloadLink("csv")} download={downloadName("csv")} target="_self"> <Link href={downloadLink("csv")} download={downloadName("csv")} target="_self">
Download as CSV File Download as CSV File
</a> </Link>
) : ( ) : (
"Download as CSV File" "Download as CSV File"
)} )}
</Menu.Item>, </Menu.Item>,
<Menu.Item key="download_tsv" disabled={isQueryResultEmpty}> <Menu.Item key="download_tsv" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? ( {!isQueryResultEmpty ? (
<a href={downloadLink("tsv")} download={downloadName("tsv")} target="_self"> <Link href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
Download as TSV File Download as TSV File
</a> </Link>
) : ( ) : (
"Download as TSV File" "Download as TSV File"
)} )}
</Menu.Item>, </Menu.Item>,
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}> <Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? ( {!isQueryResultEmpty ? (
<a href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self"> <Link href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
Download as Excel File Download as Excel File
</a> </Link>
) : ( ) : (
"Download as Excel File" "Download as Excel File"
)} )}
@@ -58,7 +59,7 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />, (canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
canViewQuery && ( canViewQuery && (
<Menu.Item key="view_query"> <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> </Menu.Item>
), ),
canEditParameters && ( canEditParameters && (
@@ -208,7 +209,10 @@ class VisualizationWidget extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { localParameters: props.widget.getLocalParameters() }; this.state = {
localParameters: props.widget.getLocalParameters(),
localFilters: props.filters,
};
} }
componentDidMount() { componentDidMount() {
@@ -218,8 +222,12 @@ class VisualizationWidget extends React.Component {
onLoad(); onLoad();
} }
onLocalFiltersChange = localFilters => {
this.setState({ localFilters });
};
expandWidget = () => { expandWidget = () => {
ExpandedWidgetDialog.showModal({ widget: this.props.widget }); ExpandedWidgetDialog.showModal({ widget: this.props.widget, filters: this.state.localFilters });
}; };
editParameterMappings = () => { editParameterMappings = () => {
@@ -259,6 +267,7 @@ class VisualizationWidget extends React.Component {
visualization={widget.visualization} visualization={widget.visualization}
queryResult={widgetQueryResult} queryResult={widgetQueryResult}
filters={filters} filters={filters}
onFiltersChange={this.onLocalFiltersChange}
context="widget" context="widget"
/> />
</div> </div>

View File

@@ -1,24 +1,29 @@
import React from "react"; import React, { useState, useReducer, useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import cx from "classnames"; import cx from "classnames";
import Form from "antd/lib/form"; 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 Button from "antd/lib/button";
import Upload from "antd/lib/upload"; import { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash";
import Icon from "antd/lib/icon";
import { includes, isFunction, filter, difference, isEmpty } from "lodash";
import Select from "antd/lib/select";
import notification from "@/services/notification"; import notification from "@/services/notification";
import Collapse from "@/components/Collapse"; import Collapse from "@/components/Collapse";
import AceEditorInput from "@/components/AceEditorInput"; import DynamicFormField, { FieldType } from "./DynamicFormField";
import { toHuman } from "@/lib/utils"; import getFieldLabel from "./getFieldLabel";
import { Field, Action, AntdForm } from "../proptypes";
import helper from "./dynamicFormHelper"; import helper from "./dynamicFormHelper";
import "./DynamicForm.less"; 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 fieldRules = ({ type, required, minLength }) => {
const requiredRule = required; const requiredRule = required;
const minLengthRule = minLength && includes(["text", "email", "password"], type); const minLengthRule = minLength && includes(["text", "email", "password"], type);
@@ -31,20 +36,200 @@ const fieldRules = ({ type, required, minLength }) => {
].filter(rule => rule); ].filter(rule => rule);
}; };
class DynamicForm extends React.Component { function normalizeEmptyValuesToNull(fields, values) {
static propTypes = { return mapValues(values, (value, key) => {
const { initialValue } = find(fields, { name: key }) || {};
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
return null;
}
return value;
});
}
function DynamicFormFields({ fields, feedbackIcons, form }) {
return fields.map(field => {
const { name, type, initialValue, contentAfter } = field;
const fieldLabel = getFieldLabel(field);
const formItemProps = {
name,
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 === "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 (
<React.Fragment key={name}>
<Form.Item {...formItemProps}>
<DynamicFormField field={field} form={form} />
</Form.Item>
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
</React.Fragment>
);
});
}
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, id: PropTypes.string,
fields: PropTypes.arrayOf(Field), fields: PropTypes.arrayOf(FieldType),
actions: PropTypes.arrayOf(Action), actions: PropTypes.arrayOf(ActionType),
feedbackIcons: PropTypes.bool, feedbackIcons: PropTypes.bool,
hideSubmitButton: PropTypes.bool, hideSubmitButton: PropTypes.bool,
defaultShowExtraFields: PropTypes.bool, defaultShowExtraFields: PropTypes.bool,
saveText: PropTypes.string, saveText: PropTypes.string,
onSubmit: PropTypes.func, onSubmit: PropTypes.func,
form: AntdForm.isRequired, };
};
static defaultProps = { DynamicForm.defaultProps = {
id: null, id: null,
fields: [], fields: [],
actions: [], actions: [],
@@ -53,260 +238,4 @@ class DynamicForm extends React.Component {
defaultShowExtraFields: false, defaultShowExtraFields: false,
saveText: "Save", saveText: "Save",
onSubmit: () => {}, onSubmit: () => {},
}; };
constructor(props) {
super(props);
const inProgressActions = {};
props.actions.forEach(action => (inProgressActions[action.name] = false));
this.state = {
isSubmitting: false,
showExtraFields: props.defaultShowExtraFields,
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 });
});
}
};
renderUpload(field, props) {
const { getFieldDecorator, getFieldValue } = this.props.form;
const { name, initialValue } = 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(
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 = {
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} />);
}
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>
);
}
}
export default Form.create()(DynamicForm);

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

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

@@ -1,14 +1,7 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import { getDynamicDateFromString } from "@/services/parameters/DateParameter";
import moment from "moment"; import DynamicDatePicker from "@/components/dynamic-parameters/DynamicDatePicker";
import { includes } from "lodash";
import { isDynamicDate, getDynamicDateFromString } from "@/services/parameters/DateParameter";
import DateInput from "@/components/DateInput";
import DateTimeInput from "@/components/DateTimeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
const DYNAMIC_DATE_OPTIONS = [ const DYNAMIC_DATE_OPTIONS = [
{ {
@@ -29,86 +22,24 @@ const DYNAMIC_DATE_OPTIONS = [
}, },
]; ];
class DateParameter extends React.Component { function DateParameter(props) {
static propTypes = { return <DynamicDatePicker dynamicButtonOptions={{ options: DYNAMIC_DATE_OPTIONS }} {...props} />;
}
DateParameter.propTypes = {
type: PropTypes.string, type: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func, onSelect: PropTypes.func,
}; };
static defaultProps = { DateParameter.defaultProps = {
type: "", type: "",
className: "", className: "",
value: null, value: null,
parameter: null, parameter: null,
onSelect: () => {}, onSelect: () => {},
}; };
constructor(props) {
super(props);
this.dateComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (parameterValue) {
onSelect(moment(parameterValue));
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateComponentRef.current.focus();
};
render() {
const { type, value, className, onSelect } = this.props;
const hasDynamicValue = isDynamicDate(value);
const isDateTime = includes(type, "datetime");
const additionalAttributes = {};
let DateComponent = DateInput;
if (isDateTime) {
DateComponent = DateTimeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (moment.isMoment(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
const dynamicDate = value;
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
additionalAttributes.value = null;
}
return (
<DateComponent
ref={this.dateComponentRef}
className={classNames("redash-datepicker", { "dynamic-value": hasDynamicValue }, className)}
onSelect={onSelect}
suffixIcon={
<DynamicButton
options={DYNAMIC_DATE_OPTIONS}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
}
{...additionalAttributes}
/>
);
}
}
export default DateParameter; export default DateParameter;

View File

@@ -1,14 +1,8 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import { includes } from "lodash";
import moment from "moment"; import { getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
import { includes, isArray, isObject } from "lodash"; import DynamicDateRangePicker from "@/components/dynamic-parameters/DynamicDateRangePicker";
import { isDynamicDateRange, getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
import DateRangeInput from "@/components/DateRangeInput";
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
const DYNAMIC_DATE_OPTIONS = [ const DYNAMIC_DATE_OPTIONS = [
{ {
@@ -134,97 +128,25 @@ const DYNAMIC_DATETIME_OPTIONS = [
...DYNAMIC_DATE_OPTIONS, ...DYNAMIC_DATE_OPTIONS,
]; ];
const widthByType = { function DateRangeParameter(props) {
"date-range": 294, const options = includes(props.type, "datetime-range") ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
"datetime-range": 352, return <DynamicDateRangePicker {...props} dynamicButtonOptions={{ options }} />;
"datetime-range-with-seconds": 382,
};
function isValidDateRangeValue(value) {
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
} }
class DateRangeParameter extends React.Component { DateRangeParameter.propTypes = {
static propTypes = {
type: PropTypes.string, type: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func, onSelect: PropTypes.func,
}; };
static defaultProps = { DateRangeParameter.defaultProps = {
type: "", type: "",
className: "", className: "",
value: null, value: null,
parameter: null, parameter: null,
onSelect: () => {}, onSelect: () => {},
}; };
constructor(props) {
super(props);
this.dateRangeComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateRangeComponentRef.current.focus();
};
render() {
const { type, value, onSelect, className } = this.props;
const isDateTimeRange = includes(type, "datetime-range");
const hasDynamicValue = isDynamicDateRange(value);
const options = isDateTimeRange ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
const additionalAttributes = {};
let DateRangeComponent = DateRangeInput;
if (isDateTimeRange) {
DateRangeComponent = DateTimeRangeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (isValidDateRangeValue(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
additionalAttributes.placeholder = [value && value.name];
additionalAttributes.value = null;
}
return (
<DateRangeComponent
ref={this.dateRangeComponentRef}
className={classNames("redash-datepicker date-range-input", { "dynamic-value": hasDynamicValue }, className)}
onSelect={onSelect}
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
suffixIcon={
<DynamicButton
options={options}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
}
{...additionalAttributes}
/>
);
}
}
export default DateRangeParameter; export default DateRangeParameter;

View File

@@ -2,17 +2,20 @@ import React, { useRef } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { isFunction, get, findIndex } from "lodash"; import { isFunction, get, findIndex } from "lodash";
import Dropdown from "antd/lib/dropdown"; import Dropdown from "antd/lib/dropdown";
import Icon from "antd/lib/icon";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Typography from "antd/lib/typography"; import Typography from "antd/lib/typography";
import { DynamicDateType } from "@/services/parameters/DateParameter"; import { DynamicDateType } from "@/services/parameters/DateParameter";
import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter"; 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"; import "./DynamicButton.less";
const { Text } = Typography; const { Text } = Typography;
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) { function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, staticValueLabel }) {
const menu = ( const menu = (
<Menu <Menu
className="dynamic-menu" className="dynamic-menu"
@@ -28,8 +31,8 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
{enabled && <Menu.Divider />} {enabled && <Menu.Divider />}
{enabled && ( {enabled && (
<Menu.Item> <Menu.Item>
<Icon type="arrow-left" /> <ArrowLeftOutlinedIcon />
<Text type="secondary">Back to Static Value</Text> <Text type="secondary">{staticValueLabel}</Text>
</Menu.Item> </Menu.Item>
)} )}
</Menu> </Menu>
@@ -45,7 +48,13 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
className="dynamic-button" className="dynamic-button"
placement="bottomRight" placement="bottomRight"
trigger={["click"]} 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} getPopupContainer={() => containerRef.current}
data-test="DynamicButton" data-test="DynamicButton"
/> />
@@ -59,6 +68,7 @@ DynamicButton.propTypes = {
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]), selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
onSelect: PropTypes.func, onSelect: PropTypes.func,
enabled: PropTypes.bool, enabled: PropTypes.bool,
staticValueLabel: PropTypes.string,
}; };
DynamicButton.defaultProps = { DynamicButton.defaultProps = {
@@ -66,6 +76,7 @@ DynamicButton.defaultProps = {
selectedDynamicValue: null, selectedDynamicValue: null,
onSelect: () => {}, onSelect: () => {},
enabled: false, enabled: false,
staticValueLabel: "Back to Static Value",
}; };
export default DynamicButton; export default DynamicButton;

View File

@@ -34,3 +34,9 @@
font-size: 11px; font-size: 11px;
} }
} }
.dynamic-icon {
display: flex !important;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,112 @@
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import moment from "moment";
import { includes } from "lodash";
import { isDynamicDate } from "@/services/parameters/DateParameter";
import DateInput from "@/components/DateInput";
import DateTimeInput from "@/components/DateTimeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
class DynamicDatePicker extends React.Component {
static propTypes = {
type: PropTypes.string,
className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
dynamicButtonOptions: PropTypes.shape({
staticValueLabel: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
value: PropTypes.object,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
})
),
}),
dateOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
};
static defaultProps = {
type: "",
className: "",
value: null,
parameter: null,
dynamicButtonOptions: {
options: [],
},
onSelect: () => {},
};
constructor(props) {
super(props);
this.dateComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (parameterValue) {
onSelect(moment(parameterValue));
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateComponentRef.current.focus();
};
render() {
const { type, value, className, dateOptions, dynamicButtonOptions, onSelect } = this.props;
const hasDynamicValue = isDynamicDate(value);
const isDateTime = includes(type, "datetime");
const additionalAttributes = {};
let DateComponent = DateInput;
if (isDateTime) {
DateComponent = DateTimeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (moment.isMoment(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
const dynamicDate = value;
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
additionalAttributes.value = null;
}
return (
<div className={classNames("date-parameter", className)}>
<DateComponent
{...dateOptions}
ref={this.dateComponentRef}
className={classNames("redash-datepicker", type, { "dynamic-value": hasDynamicValue })}
onSelect={onSelect}
suffixIcon={null}
{...additionalAttributes}
/>
<DynamicButton
options={dynamicButtonOptions.options}
staticValueLabel={dynamicButtonOptions.staticValueLabel}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
</div>
);
}
}
export default DynamicDatePicker;

View File

@@ -0,0 +1,115 @@
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import moment from "moment";
import { includes, isArray, isObject } from "lodash";
import { isDynamicDateRange } from "@/services/parameters/DateRangeParameter";
import DateRangeInput from "@/components/DateRangeInput";
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
function isValidDateRangeValue(value) {
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
}
class DynamicDateRangePicker extends React.Component {
static propTypes = {
type: PropTypes.oneOf(["date-range", "datetime-range", "datetime-range-with-seconds"]).isRequired,
className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
dynamicButtonOptions: PropTypes.shape({
staticValueLabel: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
value: PropTypes.object,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
})
),
}),
dateRangeOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
};
static defaultProps = {
type: "date-range",
className: "",
value: null,
parameter: null,
dynamicButtonOptions: {
options: [],
},
onSelect: () => {},
};
constructor(props) {
super(props);
this.dateRangeComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateRangeComponentRef.current.focus();
};
render() {
const { type, value, onSelect, className, dynamicButtonOptions, dateRangeOptions, parameter, ...rest } = this.props;
const isDateTimeRange = includes(type, "datetime-range");
const hasDynamicValue = isDynamicDateRange(value);
const additionalAttributes = {};
let DateRangeComponent = DateRangeInput;
if (isDateTimeRange) {
DateRangeComponent = DateTimeRangeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (isValidDateRangeValue(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
additionalAttributes.placeholder = [value && value.name];
additionalAttributes.value = null;
}
return (
<div {...rest} className={classNames("date-range-parameter", className)}>
<DateRangeComponent
{...dateRangeOptions}
ref={this.dateRangeComponentRef}
className={classNames("redash-datepicker date-range-input", type, { "dynamic-value": hasDynamicValue })}
onSelect={onSelect}
suffixIcon={null}
{...additionalAttributes}
/>
<DynamicButton
options={dynamicButtonOptions.options}
staticValueLabel={dynamicButtonOptions.staticValueLabel}
selectedDynamicValue={hasDynamicValue ? value : null}
enabled={hasDynamicValue}
onSelect={this.onDynamicValueSelect}
/>
</div>
);
}
}
export default DynamicDateRangePicker;

View File

@@ -1,8 +1,28 @@
@import '../../assets/less/inc/variables'; @import "../../assets/less/inc/variables";
.date-range-parameter,
.date-parameter {
position: relative;
}
.redash-datepicker { .redash-datepicker {
.ant-calendar-picker-clear { padding-right: 35px !important;
right: 35px;
&.date-range {
width: 294px;
}
&.datetime-range {
width: 352px;
}
&.datetime-range-with-seconds {
width: 382px;
}
&.dynamic-value {
width: 195px;
}
&.ant-picker-range .ant-picker-clear {
right: 35px !important;
background: transparent; background: transparent;
} }
@@ -12,19 +32,22 @@
&.dynamic-value { &.dynamic-value {
& ::placeholder { & ::placeholder {
color: @text-color !important; color: @input-color !important;
} }
&.date-range-input { &.date-range-input {
.ant-calendar-range-picker-input { .ant-picker-active-bar {
width: 100%; opacity: 0;
text-align: left;
} }
.ant-calendar-range-picker-separator, .ant-picker-separator,
.ant-calendar-range-picker-input:not(:first-child) { .ant-picker-range-separator {
display: none; display: none;
} }
.ant-picker-input:not(:first-child) {
width: 0;
}
} }
} }
} }

View File

@@ -1,18 +1,50 @@
import React from "react"; import React from "react";
export interface EmptyStateProps { 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 EmptyStateHelpMessageProps {
helpTriggerType: string;
}
export declare const EmptyStateHelpMessage: React.FunctionComponent<EmptyStateHelpMessageProps>;
export interface EmptyStateProps<K = unknown> {
header?: string; header?: string;
icon?: string; icon?: string;
description: string; description: string;
illustration: string; illustration: string;
helpLink: string; illustrationPath?: string;
helpMessage?: React.ReactNode;
closable?: boolean;
onClose?: () => void;
onboardingMode?: boolean; onboardingMode?: boolean;
showAlertStep?: boolean; showAlertStep?: boolean;
showDashboardStep?: boolean; showDashboardStep?: boolean;
showDataSourceStep?: boolean;
showInviteStep?: boolean; showInviteStep?: boolean;
getStepsItems?: (items: Array<StepItem<K>>) => Array<StepItem<K>>;
} }
declare const EmptyState: React.FunctionComponent<EmptyStateProps>; declare class EmptyState<R> extends React.Component<EmptyStateProps<R>> {}
export default EmptyState; export default EmptyState;
export interface StepProps {
show: boolean;
completed: boolean;
url?: string;
urlTarget?: string;
urlText?: React.ReactNode;
text?: React.ReactNode;
onClick?: () => void;
}
export declare const Step: React.FunctionComponent<StepProps>;

View File

@@ -2,21 +2,24 @@ import { keys, some } from "lodash";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import classNames from "classnames";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import Link from "@/components/Link";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog"; import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import HelpTrigger from "@/components/HelpTrigger";
import { currentUser } from "@/services/auth"; import { currentUser } from "@/services/auth";
import organizationStatus from "@/services/organizationStatus"; import organizationStatus from "@/services/organizationStatus";
import "./empty-state.less"; import "./empty-state.less";
function Step({ show, completed, text, url, urlText, onClick }) { export function Step({ show, completed, text, url, urlTarget, urlText, onClick }) {
if (!show) { if (!show) {
return null; return null;
} }
return ( return (
<li className={classNames({ done: completed })}> <li className={classNames({ done: completed })}>
<a href={url} onClick={onClick}> <Link href={url} onClick={onClick} target={urlTarget}>
{urlText} {urlText}
</a>{" "} </Link>{" "}
{text} {text}
</li> </li>
); );
@@ -25,31 +28,54 @@ function Step({ show, completed, text, url, urlText, onClick }) {
Step.propTypes = { Step.propTypes = {
show: PropTypes.bool.isRequired, show: PropTypes.bool.isRequired,
completed: PropTypes.bool.isRequired, completed: PropTypes.bool.isRequired,
text: PropTypes.string.isRequired, text: PropTypes.node,
url: PropTypes.string, url: PropTypes.string,
urlText: PropTypes.string, urlTarget: PropTypes.string,
urlText: PropTypes.node,
onClick: PropTypes.func, onClick: PropTypes.func,
}; };
Step.defaultProps = { Step.defaultProps = {
url: null, url: null,
urlTarget: null,
urlText: null, urlText: null,
text: null,
onClick: null, onClick: null,
}; };
export function EmptyStateHelpMessage({ helpTriggerType }) {
return (
<p>
Need more support?{" "}
<HelpTrigger className="f-14" type={helpTriggerType} showTooltip={false}>
See our Help
</HelpTrigger>
</p>
);
}
EmptyStateHelpMessage.propTypes = {
helpTriggerType: PropTypes.string.isRequired,
};
function EmptyState({ function EmptyState({
icon, icon,
header, header,
description, description,
illustration, illustration,
helpLink, helpMessage,
closable,
onClose,
onboardingMode, onboardingMode,
showAlertStep, showAlertStep,
showDashboardStep, showDashboardStep,
showDataSourceStep,
showInviteStep, showInviteStep,
getStepsItems,
illustrationPath,
}) { }) {
const isAvailable = { const isAvailable = {
dataSource: true, dataSource: showDataSourceStep,
query: true, query: true,
alert: showAlertStep, alert: showAlertStep,
dashboard: showDashboardStep, dashboard: showDashboardStep,
@@ -75,7 +101,89 @@ function EmptyState({
return null; return null;
} }
const renderDataSourcesStep = () => {
if (currentUser.isAdmin) {
return ( return (
<Step
key="dataSources"
show={isAvailable.dataSource}
completed={isCompleted.dataSource}
url="data_sources/new"
urlText="Connect 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 your first Query"
/>
),
},
{
key: "alerts",
node: (
<Step
key="alerts"
show={isAvailable.alert}
completed={isCompleted.alert}
url="alerts/new"
urlText="Create your first Alert"
/>
),
},
{
key: "dashboards",
node: (
<Step
key="dashboards"
show={isAvailable.dashboard}
completed={isCompleted.dashboard}
onClick={showCreateDashboardDialog}
urlText="Create your first Dashboard"
/>
),
},
{
key: "users",
node: (
<Step
key="users"
show={isAvailable.inviteUsers}
completed={isCompleted.inviteUsers}
url="users/new"
urlText="Invite your team members"
/>
),
},
];
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
return (
<div className="empty-state-wrapper">
<div className="empty-state bg-white tiled"> <div className="empty-state bg-white tiled">
<div className="empty-state__summary"> <div className="empty-state__summary">
{header && <h4>{header}</h4>} {header && <h4>{header}</h4>}
@@ -83,69 +191,20 @@ function EmptyState({
<i className={icon} /> <i className={icon} />
</h2> </h2>
<p>{description}</p> <p>{description}</p>
<img <img src={imageSource} alt={illustration + " Illustration"} width="75%" />
src={"/static/images/illustrations/" + illustration + ".svg"}
alt={illustration + " Illustration"}
width="75%"
/>
</div> </div>
<div className="empty-state__steps"> <div className="empty-state__steps">
<h4>Let&apos;s get started</h4> <h4>Let&apos;s get started</h4>
<ol> <ol>{stepsItems.map(item => item.node)}</ol>
{currentUser.isAdmin && ( {helpMessage}
<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>
<p>
Need more support?{" "}
<a href={helpLink} target="_blank" rel="noopener noreferrer">
See our Help
<i className="fa fa-external-link m-l-5" aria-hidden="true" />
</a>
</p>
</div> </div>
</div> </div>
{closable && (
<a className="close-button" onClick={onClose}>
<CloseOutlinedIcon />
</a>
)}
</div>
); );
} }
@@ -154,21 +213,31 @@ EmptyState.propTypes = {
header: PropTypes.string, header: PropTypes.string,
description: PropTypes.string.isRequired, description: PropTypes.string.isRequired,
illustration: PropTypes.string.isRequired, illustration: PropTypes.string.isRequired,
helpLink: PropTypes.string.isRequired, illustrationPath: PropTypes.string,
helpMessage: PropTypes.node,
closable: PropTypes.bool,
onClose: PropTypes.func,
onboardingMode: PropTypes.bool, onboardingMode: PropTypes.bool,
showAlertStep: PropTypes.bool, showAlertStep: PropTypes.bool,
showDashboardStep: PropTypes.bool, showDashboardStep: PropTypes.bool,
showDataSourceStep: PropTypes.bool,
showInviteStep: PropTypes.bool, showInviteStep: PropTypes.bool,
getStepItems: PropTypes.func,
}; };
EmptyState.defaultProps = { EmptyState.defaultProps = {
icon: null, icon: null,
header: null, header: null,
helpMessage: null,
closable: false,
onClose: () => {},
onboardingMode: false, onboardingMode: false,
showAlertStep: false, showAlertStep: false,
showDashboardStep: false, showDashboardStep: false,
showDataSourceStep: true,
showInviteStep: false, showInviteStep: false,
}; };

View File

@@ -1,3 +1,5 @@
@import (reference, less) "~@/assets/less/ant";
// Empty states // Empty states
.empty-state { .empty-state {
width: 100%; width: 100%;
@@ -19,11 +21,14 @@
padding-left: 0px; padding-left: 0px;
} }
.empty-state__summary { .empty-state__summary {
align-self: flex-start; align-self: flex-start;
text-align: center; text-align: center;
background: rgba(102, 136, 153, 0.025); background: rgba(102, 136, 153, 0.025);
p {
margin-bottom: 0;
}
} }
ol { ol {
@@ -44,10 +49,6 @@
margin-bottom: 15px; margin-bottom: 15px;
} }
p {
margin-bottom: 0;
}
a:hover { a:hover {
cursor: pointer; cursor: pointer;
} }
@@ -71,3 +72,22 @@
} }
} }
} }
// close button
.empty-state-wrapper {
position: relative;
.close-button {
position: absolute;
top: 15px;
right: 25px;
font-size: 15px;
color: @text-color-secondary;
cursor: pointer;
transition: color @animation-duration-slow;
&:hover {
color: @text-color;
}
}
}

View File

@@ -3,6 +3,42 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import hoistNonReactStatics from "hoist-non-react-statics"; import hoistNonReactStatics from "hoist-non-react-statics";
import { clientConfig } from "@/services/auth"; 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({ export const ControllerType = PropTypes.shape({
// values of props declared by wrapped component and some additional props from items list // 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 handleError: PropTypes.func.isRequired, // (error) => void
}); });
export function wrap(WrappedComponent, createItemsSource, createStateStorage) { export type GenericItemSourceError = AxiosError | Error;
class ItemsListWrapper extends React.Component {
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 = { static propTypes = {
onError: PropTypes.func, onError: PropTypes.func,
children: PropTypes.node, children: PropTypes.node,
}; };
static defaultProps = { static defaultProps = {
onError: error => { onError: (error: GenericItemSourceError) => {
// Allow calling chain to roll up, and then throw the error in global context // Allow calling chain to roll up, and then throw the error in global context
setTimeout(() => { setTimeout(() => {
throw error; throw error;
@@ -52,7 +113,7 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
children: null, children: null,
}; };
constructor(props) { constructor(props: ItemsListWrapperProps) {
super(props); super(props);
const stateStorage = createStateStorage(); const stateStorage = createStateStorage();
@@ -73,7 +134,9 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
this.setState(this.getState({ ...state, isLoaded: true })); 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 initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource; const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
@@ -93,13 +156,22 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
} }
componentWillUnmount() { componentWillUnmount() {
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onBeforeUpdate = () => {}; this._itemsSource.onBeforeUpdate = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onAfterUpdate = () => {}; this._itemsSource.onAfterUpdate = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onError = () => {}; this._itemsSource.onError = () => {};
} }
// eslint-disable-next-line class-methods-use-this // 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 { return {
...rest, ...rest,
@@ -110,9 +182,9 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
isLoaded, isLoaded,
isEmpty: !isLoaded || totalCount === 0, isEmpty: !isLoaded || totalCount === 0,
totalItemsCount: isLoaded ? totalCount : 0, totalItemsCount: totalCount || 0,
pageSizeOptions: clientConfig.pageSizeOptions, pageSizeOptions: (clientConfig as any).pageSizeOptions, // TODO: Type auth.js
pageItems: isLoaded ? pageItems : [], 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; onError = null;
sortByIteratees = undefined;
getCallbackContext = () => null; getCallbackContext = () => null;
_beforeUpdate() { _beforeUpdate() {
@@ -41,21 +43,34 @@ export class ItemsSource {
extend(customParams, params); extend(customParams, params);
}, },
}; };
return this._beforeUpdate().then(() => return this._beforeUpdate().then(() => {
this._fetcher const fetchToken = Math.random()
.toString(36)
.substr(2);
this._currentFetchToken = fetchToken;
return this._fetcher
.fetch(changes, state, context) .fetch(changes, state, context)
.then(({ results, count, allResults }) => { .then(({ results, count, allResults }) => {
if (this._currentFetchToken === fetchToken) {
this._pageItems = results; this._pageItems = results;
this._allItems = allResults || null; this._allItems = allResults || null;
this._paginator.setTotalCount(count); this._paginator.setTotalCount(count);
this._params = { ...this._params, ...customParams }; this._params = { ...this._params, ...customParams };
return this._afterUpdate(); 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)) { if (!isFunction(getRequest)) {
getRequest = identity; getRequest = identity;
} }
@@ -64,6 +79,8 @@ export class ItemsSource {
? new PlainListFetcher({ getRequest, doRequest, processResults }) ? new PlainListFetcher({ getRequest, doRequest, processResults })
: new PaginatedListFetcher({ getRequest, doRequest, processResults }); : new PaginatedListFetcher({ getRequest, doRequest, processResults });
this.sortByIteratees = sortByIteratees;
this.setState(defaultState); this.setState(defaultState);
this._pageItems = []; this._pageItems = [];
@@ -87,7 +104,7 @@ export class ItemsSource {
setState(state) { setState(state) {
this._paginator = new Paginator(state); this._paginator = new Paginator(state);
this._sorter = new Sorter(state); this._sorter = new Sorter(state, this.sortByIteratees);
this._searchTerm = state.searchTerm || ""; this._searchTerm = state.searchTerm || "";
this._selectedTags = state.selectedTags || []; this._selectedTags = state.selectedTags || [];

View File

@@ -24,6 +24,8 @@ export default class Sorter {
reverse = false; reverse = false;
sortByIteratees = null;
get compiled() { get compiled() {
return compile(this.field, this.reverse); return compile(this.field, this.reverse);
} }
@@ -42,9 +44,10 @@ export default class Sorter {
this.reverse = !!value; // cast to boolean this.reverse = !!value; // cast to boolean
} }
constructor({ orderByField, orderByReverse } = {}) { constructor({ orderByField, orderByReverse } = {}, sortByIteratees = undefined) {
this.setField(orderByField); this.setField(orderByField);
this.setReverse(orderByReverse); this.setReverse(orderByReverse);
this.sortByIteratees = sortByIteratees;
} }
toggleField(field) { toggleField(field) {
@@ -61,7 +64,8 @@ export default class Sorter {
sort(items) { sort(items) {
if (this.field) { 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) { if (this.reverse) {
items.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 React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import classNames from "classnames"; import classNames from "classnames";
import Table from "antd/lib/table"; import Table from "antd/lib/table";
import Skeleton from "antd/lib/skeleton";
import FavoritesControl from "@/components/FavoritesControl"; import FavoritesControl from "@/components/FavoritesControl";
import TimeAgo from "@/components/TimeAgo"; import TimeAgo from "@/components/TimeAgo";
import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils"; import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils";
@@ -66,10 +67,10 @@ export const Columns = {
overrides overrides
); );
}, },
timeAgo(overrides) { timeAgo(overrides, timeAgoCustomProps = undefined) {
return extend( return extend(
{ {
render: value => <TimeAgo date={value} />, render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
}, },
overrides overrides
); );
@@ -109,6 +110,8 @@ export default class ItemsTable extends React.Component {
orderByField: PropTypes.string, orderByField: PropTypes.string,
orderByReverse: PropTypes.bool, orderByReverse: PropTypes.bool,
toggleSorting: PropTypes.func, toggleSorting: PropTypes.func,
"data-test": PropTypes.string,
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
}; };
static defaultProps = { static defaultProps = {
@@ -141,7 +144,7 @@ export default class ItemsTable extends React.Component {
return extend(omit(column, ["field", "orderByField", "render"]), { return extend(omit(column, ["field", "orderByField", "render"]), {
key: "column" + index, key: "column" + index,
dataIndex: "item[" + JSON.stringify(column.field) + "]", dataIndex: ["item", column.field],
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null, defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
onHeaderCell, onHeaderCell,
render, render,
@@ -150,9 +153,22 @@ export default class ItemsTable extends React.Component {
); );
} }
getRowKey = record => {
const { rowKey } = this.props;
if (rowKey) {
if (isFunction(rowKey)) {
return rowKey(record.item);
}
return record.item[rowKey];
}
return record.key;
};
render() { render() {
const columns = this.prepareColumns(); const tableDataProps = {
const rows = map(this.props.items, (item, index) => ({ key: "row" + index, item })); columns: this.prepareColumns(),
dataSource: map(this.props.items, (item, index) => ({ key: "row" + index, item })),
};
// Bind events only if `onRowClick` specified // Bind events only if `onRowClick` specified
const onTableRow = isFunction(this.props.onRowClick) const onTableRow = isFunction(this.props.onRowClick)
@@ -164,17 +180,28 @@ export default class ItemsTable extends React.Component {
: null; : null;
const { showHeader } = this.props; 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 ( return (
<Table <Table
className={classNames("table-data", { "ant-table-headerless": !showHeader })} className={classNames("table-data", { "ant-table-headerless": !showHeader })}
loading={this.props.loading}
columns={columns}
showHeader={showHeader} showHeader={showHeader}
dataSource={rows} rowKey={this.getRowKey}
rowKey={row => row.key}
pagination={false} pagination={false}
onRow={onTableRow} onRow={onTableRow}
data-test={this.props["data-test"]}
{...tableDataProps}
/> />
); );
} }

View File

@@ -3,6 +3,7 @@ import React, { useState, useCallback, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import Input from "antd/lib/input"; import Input from "antd/lib/input";
import AntdMenu from "antd/lib/menu"; import AntdMenu from "antd/lib/menu";
import Link from "@/components/Link";
import TagsList from "@/components/TagsList"; import TagsList from "@/components/TagsList";
/* /*
@@ -59,7 +60,7 @@ export function Menu({ items, selected }) {
<AntdMenu className="invert-stripe-position" mode="inline" selectable={false} selectedKeys={[selected]}> <AntdMenu className="invert-stripe-position" mode="inline" selectable={false} selectedKeys={[selected]}>
{map(items, item => ( {map(items, item => (
<AntdMenu.Item key={item.key} className="m-0"> <AntdMenu.Item key={item.key} className="m-0">
<a href={item.href}> <Link href={item.href}>
{isString(item.icon) && item.icon !== "" && ( {isString(item.icon) && item.icon !== "" && (
<span className="btn-favourite m-r-5"> <span className="btn-favourite m-r-5">
<i className={item.icon} aria-hidden="true" /> <i className={item.icon} aria-hidden="true" />
@@ -67,7 +68,7 @@ export function Menu({ items, selected }) {
)} )}
{isFunction(item.icon) && (item.icon(item) || null)} {isFunction(item.icon) && (item.icon(item) || null)}
{item.title} {item.title}
</a> </Link>
</AntdMenu.Item> </AntdMenu.Item>
))} ))}
</AntdMenu> </AntdMenu>
@@ -131,13 +132,13 @@ ProfileImage.propTypes = {
Tags Tags
*/ */
export function Tags({ url, onChange }) { export function Tags({ url, onChange, showUnselectAll }) {
if (url === "") { if (url === "") {
return null; return null;
} }
return ( return (
<div className="m-b-10"> <div className="m-b-10">
<TagsList tagsUrl={url} onUpdate={onChange} /> <TagsList tagsUrl={url} onUpdate={onChange} showUnselectAll={showUnselectAll} />
</div> </div>
); );
} }
@@ -145,4 +146,6 @@ export function Tags({ url, onChange }) {
Tags.propTypes = { Tags.propTypes = {
url: PropTypes.string.isRequired, url: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
showUnselectAll: PropTypes.bool,
unselectAllButtonTitle: PropTypes.string,
}; };

View File

@@ -0,0 +1,77 @@
import { filter, includes, intersection } from "lodash";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import Checkbox from "antd/lib/checkbox";
import { Columns } from "../components/ItemsTable";
export default function useItemsListExtraActions(controller, listColumns, ExtraActionsComponent) {
const [actionsState, setActionsState] = useState({ isAvailable: false });
const [selectedItems, setSelectedItems] = useState([]);
// clear selection when page changes
useEffect(() => {
setSelectedItems([]);
}, [controller.pageItems, actionsState.isAvailable]);
const areAllItemsSelected = useMemo(() => {
const allItems = controller.pageItems;
if (allItems.length === 0 || selectedItems.length === 0) {
return false;
}
return intersection(selectedItems, allItems).length === allItems.length;
}, [selectedItems, controller.pageItems]);
const toggleAllItems = useCallback(() => {
if (areAllItemsSelected) {
setSelectedItems([]);
} else {
setSelectedItems(controller.pageItems);
}
}, [areAllItemsSelected, controller.pageItems]);
const toggleItem = useCallback(
item => {
if (includes(selectedItems, item)) {
setSelectedItems(filter(selectedItems, s => s !== item));
} else {
setSelectedItems([...selectedItems, item]);
}
},
[selectedItems]
);
const checkboxColumn = useMemo(
() =>
Columns.custom(
(text, item) => <Checkbox checked={includes(selectedItems, item)} onChange={() => toggleItem(item)} />,
{
title: () => <Checkbox checked={areAllItemsSelected} onChange={toggleAllItems} />,
field: "id",
width: "1%",
}
),
[selectedItems, areAllItemsSelected, toggleAllItems, toggleItem]
);
const Component = useCallback(
function ItemsListExtraActionsComponentWrapper(props) {
// this check mostly needed to avoid eslint exhaustive deps warning
if (!ExtraActionsComponent) {
return null;
}
return <ExtraActionsComponent onStateChange={setActionsState} {...props} />;
},
[ExtraActionsComponent]
);
return useMemo(
() => ({
areExtraActionsAvailable: actionsState.isAvailable,
listColumns: actionsState.isAvailable ? [checkboxColumn, ...listColumns] : listColumns,
Component,
selectedItems,
setSelectedItems,
}),
[actionsState, listColumns, checkboxColumn, selectedItems, Component]
);
}

View File

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

View File

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

View File

@@ -31,53 +31,6 @@ export const RefreshScheduleDefault = {
until: null, 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({ export const UserProfile = PropTypes.shape({
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,

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